diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 8373e498a..e6d73588b 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -93,6 +93,9 @@ type Client struct { stateMu sync.RWMutex connectClient *internal.ConnectClient config *profilemanager.Config + + fileDropMu sync.Mutex + fileDrop *FileDrop } // NewClient instantiate a new Client @@ -189,6 +192,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, internal.WithNetEvents(c.netMgr)) c.setState(cfg, connectClient) + c.attachFileDrop(connectClient, c.cfgFile) // Persist the latest sync response so DebugBundle can include the network // map. On iOS this is backed by disk to keep it out of the constrained // process memory (see the syncstore package). diff --git a/client/ios/NetBirdSDK/client_filedrop.go b/client/ios/NetBirdSDK/client_filedrop.go new file mode 100644 index 000000000..9afc20bfd --- /dev/null +++ b/client/ios/NetBirdSDK/client_filedrop.go @@ -0,0 +1,65 @@ +//go:build ios + +package NetBirdSDK + +import ( + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/mobile" +) + +// FileDropHandle returns the file drop handle of the client's profile, creating it on first use. +func (c *Client) FileDropHandle() (*FileDrop, error) { + configDir, profileID, err := mobile.ProfileLocationFor(c.cfgFile) + if err != nil { + return nil, err + } + return c.fileDropFor(configDir, profileID) +} + +func (c *Client) fileDropFor(configDir, profileID string) (*FileDrop, error) { + c.fileDropMu.Lock() + + if c.fileDrop != nil && c.fileDrop.ProfileID() == profileID { + fd := c.fileDrop + c.fileDropMu.Unlock() + return fd, nil + } + + fd, err := NewFileDrop(configDir, profileID) + if err != nil { + c.fileDropMu.Unlock() + return nil, err + } + + old := c.fileDrop + if old != nil { + fd.SetListener(old.Listener()) + } + c.fileDrop = fd + c.fileDropMu.Unlock() + + if old != nil { + if err := old.Close(); err != nil { + log.Warnf("failed to close previous file drop manager: %v", err) + } + } + + return fd, nil +} + +func (c *Client) attachFileDrop(cc *internal.ConnectClient, cfgFile string) { + configDir, profileID, err := mobile.ProfileLocationFor(cfgFile) + if err != nil { + log.Warnf("file drop is unavailable: %v", err) + return + } + + fd, err := c.fileDropFor(configDir, profileID) + if err != nil { + log.Warnf("file drop is unavailable: %v", err) + return + } + cc.SetFileDropManager(fd.manager) +} diff --git a/client/ios/NetBirdSDK/filedrop.go b/client/ios/NetBirdSDK/filedrop.go new file mode 100644 index 000000000..bfa86318f --- /dev/null +++ b/client/ios/NetBirdSDK/filedrop.go @@ -0,0 +1,198 @@ +//go:build ios + +package NetBirdSDK + +import ( + "errors" + "fmt" + "net/netip" + "path/filepath" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/filedrop" + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +const filedropDataSubdir = "filedrop" + +// FileDrop is the platform-facing handle on one profile's file drop state. +type FileDrop struct { + mu sync.Mutex + configDir string + profileID string + manager *filedrop.Manager + listener FileDropListener +} + +// NewFileDrop opens the file drop state of the given profile. +func NewFileDrop(configDir, profileID string) (*FileDrop, error) { + if configDir == "" || profileID == "" { + return nil, errors.New("file drop requires a config dir and profile ID") + } + + prefs, err := NewProfileManager(configDir).impl.ProfilePrefs(profileID) + if err != nil { + return nil, err + } + + fd := &FileDrop{configDir: configDir, profileID: profileID} + manager, err := filedrop.NewManager(filedrop.ManagerConfig{ + Profile: profilemanager.ID(profileID), + DataDir: filepath.Join(configDir, filedropDataSubdir, profileID), + Store: filedrop.NewProfileStore(prefs), + Events: fd.publish, + }) + if err != nil { + return nil, fmt.Errorf("create file drop manager: %w", err) + } + + fd.manager = manager + ensureFileDropDestination(fd) + return fd, nil +} + +// ProfileID returns the profile this handle belongs to. +func (f *FileDrop) ProfileID() string { + return f.profileID +} + +// SetListener installs the event listener, replacing any previous one. +func (f *FileDrop) SetListener(listener FileDropListener) { + f.mu.Lock() + defer f.mu.Unlock() + f.listener = listener +} + +// Listener returns the installed event listener, nil when there is none. +func (f *FileDrop) Listener() FileDropListener { + f.mu.Lock() + defer f.mu.Unlock() + return f.listener +} + +// RemoveListener stops event delivery. +func (f *FileDrop) RemoveListener() { + f.mu.Lock() + defer f.mu.Unlock() + f.listener = nil +} + +// Send starts an asynchronous transfer and returns its local transfer ID. +func (f *FileDrop) Send(peerKey, peerName, peerIP string, payloads *FileDropPayloads) (string, error) { + if payloads == nil || payloads.Length() == 0 { + return "", errors.New("nothing to send") + } + + addr, err := netip.ParseAddr(peerIP) + if err != nil { + return "", fmt.Errorf("parse peer address %q: %w", peerIP, err) + } + + id, err := f.manager.Send(filedrop.PeerKey(peerKey), peerName, addr.Unmap(), payloads.items) + if err != nil { + return "", err + } + return string(id), nil +} + +// Accept releases a pending incoming offer for download. +func (f *FileDrop) Accept(transferID string) error { + return f.manager.Accept(filedrop.OfferID(transferID)) +} + +// Decline refuses a pending incoming offer. +func (f *FileDrop) Decline(transferID string) error { + return f.manager.Decline(filedrop.OfferID(transferID)) +} + +// Cancel aborts a transfer in either direction. +func (f *FileDrop) Cancel(transferID string) { + f.manager.Cancel(filedrop.OfferID(transferID)) +} + +// Transfers returns the history, newest first. +func (f *FileDrop) Transfers() *FileDropTransferArray { + transfers := f.manager.Transfers() + items := make([]*FileDropTransfer, 0, len(transfers)) + for _, t := range transfers { + items = append(items, toFileDropTransfer(t)) + } + return &FileDropTransferArray{items: items} +} + +// Transfer returns one history entry, or nil when it is unknown. +func (f *FileDrop) Transfer(transferID string) *FileDropTransfer { + for _, t := range f.manager.Transfers() { + if string(t.ID) == transferID { + return toFileDropTransfer(t) + } + } + return nil +} + +// DeleteTransfer removes one history entry, cancelling it when still live. +func (f *FileDrop) DeleteTransfer(transferID string) { + f.manager.DeleteTransfer(filedrop.OfferID(transferID)) +} + +// Mode returns the base receiving mode. +func (f *FileDrop) Mode() int { + return int(f.manager.Policy().Get().Mode) +} + +// SetMode changes the base receiving mode. +func (f *FileDrop) SetMode(mode int) error { + return f.manager.Policy().SetMode(filedrop.Mode(mode)) +} + +// DestinationDir returns the directory received files are delivered to. +func (f *FileDrop) DestinationDir() string { + return f.manager.DestinationDir() +} + +// SetDestinationDir persists the delivery directory. +func (f *FileDrop) SetDestinationDir(dir string) error { + return f.manager.SetDestinationDir(dir) +} + +// PeerRule returns the rule stored for one sender. +func (f *FileDrop) PeerRule(peerKey string) int { + return int(f.manager.Policy().Get().Senders[filedrop.PeerKey(peerKey)]) +} + +// SetPeerRule sets or clears the exception for one sender. +func (f *FileDrop) SetPeerRule(peerKey string, rule int) error { + return f.manager.SetSenderRule(filedrop.PeerKey(peerKey), filedrop.SenderRule(rule)) +} + +// Close stops the receiver and aborts every outgoing transfer. +func (f *FileDrop) Close() error { + f.RemoveListener() + return f.manager.Close() +} + +func (f *FileDrop) publish(kind filedrop.EventKind, transfer filedrop.Transfer) { + f.mu.Lock() + listener := f.listener + f.mu.Unlock() + if listener == nil { + return + } + listener.OnFileDropEvent(int(kind), toFileDropTransfer(transfer)) +} + +func defaultFileDropDir(configDir, profileID string) string { + return filepath.Join(configDir, filedropDataSubdir, profileID, "incoming") +} + +func ensureFileDropDestination(fd *FileDrop) { + if fd.DestinationDir() != "" { + return + } + dir := defaultFileDropDir(fd.configDir, fd.profileID) + if err := fd.SetDestinationDir(dir); err != nil { + log.Warnf("failed to set default file drop destination: %v", err) + } +} diff --git a/client/ios/NetBirdSDK/filedrop_payload.go b/client/ios/NetBirdSDK/filedrop_payload.go new file mode 100644 index 000000000..53edd579a --- /dev/null +++ b/client/ios/NetBirdSDK/filedrop_payload.go @@ -0,0 +1,71 @@ +//go:build ios + +package NetBirdSDK + +import ( + "errors" + "fmt" + "io" + "os" + + "github.com/netbirdio/netbird/client/internal/filedrop" +) + +// FileDropPayloads collects the items of one outgoing transfer. +type FileDropPayloads struct { + items []filedrop.Payload +} + +// NewFileDropPayloads returns an empty payload list to fill before sending. +func NewFileDropPayloads() *FileDropPayloads { + return &FileDropPayloads{} +} + +// AddFile appends a file item backed by a filesystem path the extension can read. +func (p *FileDropPayloads) AddFile(name string, size int64, contentType, path string) error { + if name == "" { + return errors.New("file name is required") + } + if path == "" { + return fmt.Errorf("file %s has no path", name) + } + + p.items = append(p.items, filedrop.Payload{ + Meta: filedrop.FileMeta{ + Name: name, + Size: size, + ContentType: contentType, + }, + Open: func(offset int64) (io.ReadCloser, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + if offset > 0 { + if _, err := f.Seek(offset, io.SeekStart); err != nil { + _ = f.Close() + return nil, err + } + } + return f, nil + }, + }) + return nil +} + +// AddText appends an inline text item. +func (p *FileDropPayloads) AddText(name, text string) error { + if len(text) > filedrop.MaxInlineTextSize { + return fmt.Errorf("text exceeds %d bytes", filedrop.MaxInlineTextSize) + } + if name == "" { + name = "text" + } + p.items = append(p.items, filedrop.TextPayload(name, text)) + return nil +} + +// Length returns the number of items. +func (p *FileDropPayloads) Length() int { + return len(p.items) +} diff --git a/client/ios/NetBirdSDK/filedrop_test.go b/client/ios/NetBirdSDK/filedrop_test.go new file mode 100644 index 000000000..de0fc41dc --- /dev/null +++ b/client/ios/NetBirdSDK/filedrop_test.go @@ -0,0 +1,182 @@ +//go:build ios + +package NetBirdSDK + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/netbirdio/netbird/client/internal/filedrop" +) + +func TestPayloadFileReadsFromPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "greeting.txt") + if err := os.WriteFile(path, []byte("hello world"), 0o600); err != nil { + t.Fatalf("write payload: %v", err) + } + + payloads := NewFileDropPayloads() + if err := payloads.AddFile("greeting.txt", 11, "text/plain", path); err != nil { + t.Fatalf("AddFile: %v", err) + } + if payloads.Length() != 1 { + t.Fatalf("expected 1 payload, got %d", payloads.Length()) + } + + stream, err := payloads.items[0].Open(0) + if err != nil { + t.Fatalf("Open: %v", err) + } + got, err := io.ReadAll(stream) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if string(got) != "hello world" { + t.Fatalf("got %q, want %q", got, "hello world") + } + if err := stream.Close(); err != nil { + t.Fatalf("Close: %v", err) + } +} + +func TestPayloadFileHonoursOffset(t *testing.T) { + path := filepath.Join(t.TempDir(), "greeting.txt") + if err := os.WriteFile(path, []byte("hello world"), 0o600); err != nil { + t.Fatalf("write payload: %v", err) + } + + payloads := NewFileDropPayloads() + if err := payloads.AddFile("greeting.txt", 11, "", path); err != nil { + t.Fatalf("AddFile: %v", err) + } + + stream, err := payloads.items[0].Open(6) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer stream.Close() + + got, err := io.ReadAll(stream) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if string(got) != "world" { + t.Fatalf("got %q, want %q", got, "world") + } +} + +func TestPayloadRejectsMissingPathAndOversizedText(t *testing.T) { + payloads := NewFileDropPayloads() + + if err := payloads.AddFile("no-path.bin", 1, "", ""); err == nil { + t.Fatal("expected an error for a file without a path") + } + if err := payloads.AddFile("", 1, "", "/tmp/x"); err == nil { + t.Fatal("expected an error for an empty file name") + } + if err := payloads.AddText("big", strings.Repeat("x", filedrop.MaxInlineTextSize+1)); err == nil { + t.Fatal("expected an error for oversized text") + } + if payloads.Length() != 0 { + t.Fatalf("expected no payloads, got %d", payloads.Length()) + } +} + +func TestFileDropPersistsSettingsPerProfile(t *testing.T) { + configDir := t.TempDir() + writeTestProfile(t, configDir, "aaaaaaaabbbbbbbbccccccccdddddddd") + writeTestProfile(t, configDir, "11111111222222223333333344444444") + + first, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd") + if err != nil { + t.Fatalf("NewFileDrop: %v", err) + } + defer first.Close() + + if err := first.SetMode(FileDropModeAutoAccept); err != nil { + t.Fatalf("SetMode: %v", err) + } + if err := first.SetPeerRule("peer-key", FileDropRuleBlock); err != nil { + t.Fatalf("SetPeerRule: %v", err) + } + + second, err := NewFileDrop(configDir, "11111111222222223333333344444444") + if err != nil { + t.Fatalf("NewFileDrop: %v", err) + } + defer second.Close() + + if got := second.Mode(); got != FileDropModeAsk { + t.Fatalf("second profile mode = %d, want the default %d", got, FileDropModeAsk) + } + if got := second.PeerRule("peer-key"); got != FileDropRuleDefault { + t.Fatalf("second profile rule = %d, want %d", got, FileDropRuleDefault) + } + + reopened, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd") + if err != nil { + t.Fatalf("NewFileDrop: %v", err) + } + defer reopened.Close() + + if got := reopened.Mode(); got != FileDropModeAutoAccept { + t.Fatalf("reopened mode = %d, want %d", got, FileDropModeAutoAccept) + } + if got := reopened.PeerRule("peer-key"); got != FileDropRuleBlock { + t.Fatalf("reopened rule = %d, want %d", got, FileDropRuleBlock) + } +} + +func TestFileDropSeedsDefaultDestination(t *testing.T) { + configDir := t.TempDir() + writeTestProfile(t, configDir, "aaaaaaaabbbbbbbbccccccccdddddddd") + + fd, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd") + if err != nil { + t.Fatalf("NewFileDrop: %v", err) + } + defer fd.Close() + + want := filepath.Join(configDir, filedropDataSubdir, "aaaaaaaabbbbbbbbccccccccdddddddd", "incoming") + if got := fd.DestinationDir(); got != want { + t.Fatalf("destination = %q, want %q", got, want) + } +} + +func TestFileDropSendWithoutTunnelFails(t *testing.T) { + configDir := t.TempDir() + writeTestProfile(t, configDir, "aaaaaaaabbbbbbbbccccccccdddddddd") + + fd, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd") + if err != nil { + t.Fatalf("NewFileDrop: %v", err) + } + defer fd.Close() + + payloads := NewFileDropPayloads() + if err := payloads.AddText("note", "hi"); err != nil { + t.Fatalf("AddText: %v", err) + } + + if _, err := fd.Send("peer-key", "peer", "100.64.0.2", payloads); !errors.Is(err, filedrop.ErrNotConnected) { + t.Fatalf("Send error = %v, want %v", err, filedrop.ErrNotConnected) + } + if _, err := fd.Send("peer-key", "peer", "100.64.0.2", NewFileDropPayloads()); err == nil { + t.Fatal("expected an error when there is nothing to send") + } + if _, err := fd.Send("peer-key", "peer", "not-an-ip", payloads); err == nil { + t.Fatal("expected an error for an unparseable peer address") + } +} + +func writeTestProfile(t *testing.T, configDir, id string) { + t.Helper() + + if _, err := NewProfileManager(configDir).impl.ProfilePrefs(id); err != nil { + t.Fatalf("resolve prefs for %s: %v", id, err) + } +} diff --git a/client/ios/NetBirdSDK/filedrop_transfer.go b/client/ios/NetBirdSDK/filedrop_transfer.go new file mode 100644 index 000000000..0a0195b68 --- /dev/null +++ b/client/ios/NetBirdSDK/filedrop_transfer.go @@ -0,0 +1,164 @@ +//go:build ios + +package NetBirdSDK + +import ( + "strings" + "time" + + "github.com/netbirdio/netbird/client/internal/filedrop" +) + +// The file drop receiving modes exported via gomobile. +const ( + FileDropModeOff = int(filedrop.ModeOff) + FileDropModeAsk = int(filedrop.ModeAsk) + FileDropModeAutoAccept = int(filedrop.ModeAutoAccept) +) + +// The per-sender rules exported via gomobile. +const ( + FileDropRuleDefault = int(filedrop.SenderRuleDefault) + FileDropRuleAlwaysAccept = int(filedrop.SenderRuleAlwaysAccept) + FileDropRuleBlock = int(filedrop.SenderRuleBlock) +) + +// The transfer states exported via gomobile. +const ( + FileDropStatePending = int(filedrop.StatePending) + FileDropStateTransferring = int(filedrop.StateTransferring) + FileDropStateCompleted = int(filedrop.StateCompleted) + FileDropStateDeclined = int(filedrop.StateDeclined) + FileDropStateExpired = int(filedrop.StateExpired) + FileDropStateCancelled = int(filedrop.StateCancelled) + FileDropStateFailed = int(filedrop.StateFailed) +) + +// The failure reasons exported via gomobile. +const ( + FileDropReasonNone = int(filedrop.ReasonNone) + FileDropReasonUnreachable = int(filedrop.ReasonUnreachable) +) + +// The event kinds delivered to a FileDropListener. +const ( + FileDropEventOffer = int(filedrop.EventOffer) + FileDropEventCompleted = int(filedrop.EventCompleted) + FileDropEventFailed = int(filedrop.EventFailed) + FileDropEventWithdrawn = int(filedrop.EventWithdrawn) + FileDropEventProgress = int(filedrop.EventProgress) +) + +// FileDropListener receives transfer events. Calls arrive on background +// goroutines, so implementations must post to the UI thread themselves. +type FileDropListener interface { + OnFileDropEvent(kind int, transfer *FileDropTransfer) +} + +// FileDropFile is one item of a transfer. +type FileDropFile struct { + Name string + Size int64 + ContentType string + IsText bool + Text string +} + +// FileDropTransfer is one history entry. +type FileDropTransfer struct { + ID string + Outgoing bool + PeerKey string + PeerName string + State int + Transferred int64 + TotalSize int64 + // Unix milliseconds, so the platform layer can render the time in the + // user's own locale and zone rather than parsing a preformatted string. + CreatedAtMillis int64 + UpdatedAtMillis int64 + // IsText marks a transfer that is a single inline snippet rather than + // files, so the UI can drop the size and offer a copy action instead. + IsText bool + Error string + Reason int + + files []*FileDropFile + deliveredPaths []string +} + +// FileDropTransferArray wraps transfers for gomobile compatibility. +type FileDropTransferArray struct { + items []*FileDropTransfer +} + +// FileCount returns the number of items in the transfer. +func (t *FileDropTransfer) FileCount() int { + return len(t.files) +} + +// GetFile returns the item at index i, or nil when out of range. +func (t *FileDropTransfer) GetFile(i int) *FileDropFile { + if i < 0 || i >= len(t.files) { + return nil + } + return t.files[i] +} + +// DeliveredPaths returns the delivered file paths joined by newlines, so the +// platform layer can move them into user-visible storage. +func (t *FileDropTransfer) DeliveredPaths() string { + return strings.Join(t.deliveredPaths, "\n") +} + +// Length returns the number of transfers. +func (a *FileDropTransferArray) Length() int { + return len(a.items) +} + +// Get returns the transfer at index i, or nil when out of range. +func (a *FileDropTransferArray) Get(i int) *FileDropTransfer { + if i < 0 || i >= len(a.items) { + return nil + } + return a.items[i] +} + +func toFileDropTransfer(t filedrop.Transfer) *FileDropTransfer { + files := make([]*FileDropFile, 0, len(t.Files)) + for _, f := range t.Files { + files = append(files, &FileDropFile{ + Name: f.Name, + Size: f.Size, + ContentType: f.ContentType, + IsText: f.Kind == filedrop.KindText, + Text: f.Text, + }) + } + + return &FileDropTransfer{ + ID: string(t.ID), + Outgoing: t.Direction == filedrop.DirectionSent, + PeerKey: string(t.PeerKey), + PeerName: t.PeerName, + State: int(t.State), + Transferred: t.Transferred, + TotalSize: t.TotalSize, + CreatedAtMillis: unixMillis(t.CreatedAt), + UpdatedAtMillis: unixMillis(t.UpdatedAt), + IsText: len(t.Files) == 1 && t.Files[0].Kind == filedrop.KindText, + Error: t.Error, + Reason: int(t.Reason), + files: files, + deliveredPaths: t.DeliveredPaths, + } +} + +// unixMillis renders a timestamp for the platform layer, mapping the zero time +// to 0 so it reads as "unknown" rather than as 1970. +func unixMillis(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UnixMilli() +}