mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-12 17:59:06 +02:00
Seed the delivery directory from the IPC caller when the active profile carries no username, so the default profile gets a destination instead of failing every incoming transfer. Deliver text-only offers without requiring a destination directory at all. In the UI, drop the status label from the send picker rows, keep the dot, and give the transfer list per-kind icons: a quote for text, a file with an up or down arrow for files.
441 lines
12 KiB
Go
441 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/netip"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
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(ctx context.Context) (*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()
|
|
seedFileDropDestination(ctx, mgr, activeProf.Username)
|
|
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(ctx, mgr, activeProf.Username)
|
|
|
|
s.mutex.Lock()
|
|
if s.fileDrop != nil && s.fileDrop.Profile() == activeProf.ID {
|
|
winner := s.fileDrop
|
|
s.mutex.Unlock()
|
|
_ = mgr.Close()
|
|
seedFileDropDestination(ctx, winner, activeProf.Username)
|
|
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(ctx)
|
|
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(ctx context.Context, req *proto.FileDropDecideRequest) (*proto.FileDropDecideResponse, error) {
|
|
mgr, err := s.fileDropManager(ctx)
|
|
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(ctx context.Context, req *proto.FileDropCancelRequest) (*proto.FileDropCancelResponse, error) {
|
|
mgr, err := s.fileDropManager(ctx)
|
|
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(ctx context.Context, _ *proto.FileDropListTransfersRequest) (*proto.FileDropListTransfersResponse, error) {
|
|
mgr, err := s.fileDropManager(ctx)
|
|
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(ctx context.Context, req *proto.FileDropDeleteTransferRequest) (*proto.FileDropDeleteTransferResponse, error) {
|
|
mgr, err := s.fileDropManager(ctx)
|
|
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(ctx context.Context, _ *proto.FileDropGetSettingsRequest) (*proto.FileDropGetSettingsResponse, error) {
|
|
mgr, err := s.fileDropManager(ctx)
|
|
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(ctx)
|
|
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(ctx context.Context, req *proto.FileDropSetPeerRuleRequest) (*proto.FileDropSetPeerRuleResponse, error) {
|
|
mgr, err := s.fileDropManager(ctx)
|
|
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(ctx context.Context, mgr *filedrop.Manager, username string) {
|
|
if mgr.DestinationDir() != "" {
|
|
return
|
|
}
|
|
|
|
u, ok := fileDropDestinationUser(ctx, username)
|
|
if !ok {
|
|
return
|
|
}
|
|
if u.HomeDir == "" {
|
|
log.Warnf("user %s has no home directory for the file drop destination", u.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)
|
|
}
|
|
}
|
|
|
|
func fileDropDestinationUser(ctx context.Context, username string) (*user.User, bool) {
|
|
if username != "" {
|
|
u, err := user.Lookup(username)
|
|
if err != nil {
|
|
log.Warnf("cannot resolve home of %s for the file drop destination: %v", username, err)
|
|
return nil, false
|
|
}
|
|
return u, true
|
|
}
|
|
|
|
caller, ok := ipcauth.CallerIdentity(ctx)
|
|
if !ok || caller.IsPrivileged() {
|
|
return nil, false
|
|
}
|
|
id := caller.SID
|
|
if !caller.IsWindows() {
|
|
id = strconv.FormatUint(uint64(caller.UID), 10)
|
|
}
|
|
u, err := user.LookupId(id)
|
|
if err != nil {
|
|
log.Warnf("cannot resolve home of caller %s for the file drop destination: %v", id, err)
|
|
return nil, false
|
|
}
|
|
return u, true
|
|
}
|