diff --git a/client/android/client.go b/client/android/client.go index 71bbe4380..77f2c6d57 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -114,6 +114,11 @@ type Client struct { extendMu sync.Mutex extendCancel context.CancelFunc + + // The file drop handle survives engine restarts so the UI keeps one listener + // registration and one history view across reconnects. See fileDropFor. + fileDropMu sync.Mutex + fileDrop *FileDrop } func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) { @@ -204,6 +209,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid ctx = internal.CtxInitState(ctx) connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) + c.attachFileDrop(connectClient, cfgFile) c.setState(cfg, cacheDir, cfgFile, connectClient) // This path runs the interactive SSO flow, so reaching here means the peer // is authenticated again — release the latch Status() reports from. Clear @@ -246,6 +252,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR ctx = internal.CtxInitState(ctx) connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) + c.attachFileDrop(connectClient, cfgFile) c.setState(cfg, cacheDir, cfgFile, connectClient) return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } diff --git a/client/android/client_filedrop.go b/client/android/client_filedrop.go new file mode 100644 index 000000000..8576bd6eb --- /dev/null +++ b/client/android/client_filedrop.go @@ -0,0 +1,78 @@ +//go:build android + +package android + +import ( + "fmt" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal" +) + +// FileDrop returns the handle of the active profile, creating it on first use. +// The UI calls this to list transfers and change settings while disconnected. +func (c *Client) FileDrop(configDir string) (*FileDrop, error) { + profile, err := NewProfileManager(configDir).GetActiveProfile() + if err != nil { + return nil, fmt.Errorf("get active profile: %w", err) + } + return c.fileDropFor(configDir, profile.ID) +} + +// fileDropFor returns the handle of one profile, replacing the cached one when +// the profile changed. The listener is carried over so a profile switch does not +// silence the UI. +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 + } + ensureFileDropDestination(fd) + + old := c.fileDrop + if old != nil { + fd.SetListener(old.Listener()) + } + c.fileDrop = fd + c.fileDropMu.Unlock() + + // Closing waits out the in-flight uploads of the profile being left, which is + // far too long to hold the lock every caller of this goes through. + if old != nil { + if err := old.Close(); err != nil { + log.Warnf("failed to close previous file drop manager: %v", err) + } + } + + return fd, nil +} + +// attachFileDrop hands the connect client the file drop manager of the profile +// the engine is starting for. The profile is derived from the config path rather +// than read from the active profile state, so a switch racing the startup cannot +// pair one profile's engine with another's transfers. A failure is not fatal: +// the tunnel is worth more than the feature, so the engine runs on without it. +func (c *Client) attachFileDrop(cc *internal.ConnectClient, cfgFile string) { + configDir, profileID, err := 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/android/filedrop.go b/client/android/filedrop.go new file mode 100644 index 000000000..daa4f8ada --- /dev/null +++ b/client/android/filedrop.go @@ -0,0 +1,205 @@ +//go:build android + +package android + +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. It +// outlives the engine: the manager keeps policy and history readable while the +// tunnel is down, and sending simply fails until it comes back up. +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 := newProfilePrefs(configDir, 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.prefs), + Events: fd.publish, + }) + if err != nil { + return nil, fmt.Errorf("create file drop manager: %w", err) + } + + fd.manager = manager + 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. It must be a filesystem +// path the app can write; content URIs are not paths, so the platform layer +// moves files out of this directory afterwards. +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)) +} + +// defaultFileDropDir is the app-private landing directory used until the +// platform layer configures one. +func defaultFileDropDir(configDir, profileID string) string { + return filepath.Join(configDir, filedropDataSubdir, profileID, "incoming") +} + +// ensureFileDropDestination seeds the delivery directory on first use, so a +// received file always has somewhere to land. +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/android/filedrop_payload.go b/client/android/filedrop_payload.go new file mode 100644 index 000000000..1075f51d2 --- /dev/null +++ b/client/android/filedrop_payload.go @@ -0,0 +1,122 @@ +//go:build android + +package android + +import ( + "errors" + "fmt" + "io" + + "github.com/netbirdio/netbird/client/internal/filedrop" +) + +// FileSource opens the bytes of one outgoing item. Android hands out content URIs +// rather than paths, so the platform layer owns opening and seeking. +type FileSource interface { + // Open returns a stream positioned at offset. It is called once per attempt, + // and again from the start when a transfer resumes. + Open(offset int64) (SourceStream, error) +} + +// SourceStream is the readable half of a FileSource. +// +// It returns each chunk instead of filling a caller-supplied buffer: 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. Only +// the return value crosses the bridge intact. +type SourceStream interface { + // NextChunk returns up to max bytes. An empty result means end of stream. + NextChunk(max int) ([]byte, error) + Close() error +} + +type sourceStreamReader struct { + stream SourceStream + buf []byte + eof bool +} + +// 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 platform-provided source. +func (p *FileDropPayloads) AddFile(name string, size int64, contentType string, source FileSource) error { + if name == "" { + return errors.New("file name is required") + } + if source == nil { + return fmt.Errorf("file %s has no source", name) + } + + p.items = append(p.items, filedrop.Payload{ + Meta: filedrop.FileMeta{ + Name: name, + Size: size, + ContentType: contentType, + }, + Open: func(offset int64) (io.ReadCloser, error) { + stream, err := source.Open(offset) + if err != nil { + return nil, err + } + if stream == nil { + return nil, fmt.Errorf("no stream for %s", name) + } + return &sourceStreamReader{stream: stream}, 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) +} + +func (r *sourceStreamReader) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + for len(r.buf) == 0 { + if r.eof { + return 0, io.EOF + } + chunk, err := r.stream.NextChunk(len(p)) + if err != nil { + return 0, err + } + if len(chunk) == 0 { + r.eof = true + return 0, io.EOF + } + r.buf = chunk + } + + n := copy(p, r.buf) + r.buf = r.buf[n:] + return n, nil +} + +func (r *sourceStreamReader) Close() error { + return r.stream.Close() +} diff --git a/client/android/filedrop_test.go b/client/android/filedrop_test.go new file mode 100644 index 000000000..c77b9e221 --- /dev/null +++ b/client/android/filedrop_test.go @@ -0,0 +1,261 @@ +//go:build android + +package android + +import ( + "errors" + "io" + "path/filepath" + "strings" + "testing" + + "github.com/netbirdio/netbird/client/internal/filedrop" + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +type stubStream struct { + reader io.Reader + closed bool + // chunk caps what one call returns, so the reader's buffering is exercised + // rather than every read landing in a single hop. + chunk int +} + +type stubSource struct { + content string + offsets []int64 + chunk int +} + +func (s *stubStream) NextChunk(max int) ([]byte, error) { + if s.chunk > 0 && s.chunk < max { + max = s.chunk + } + buf := make([]byte, max) + + n, err := s.reader.Read(buf) + if errors.Is(err, io.EOF) || n == 0 { + return nil, nil + } + if err != nil { + return nil, err + } + return buf[:n], nil +} + +func (s *stubStream) Close() error { + s.closed = true + return nil +} + +func (s *stubSource) Open(offset int64) (SourceStream, error) { + s.offsets = append(s.offsets, offset) + return &stubStream{reader: strings.NewReader(s.content[offset:]), chunk: s.chunk}, nil +} + +func TestPayloadSourceReassemblesChunks(t *testing.T) { + for name, chunk := range map[string]int{ + "one hop": 0, + "three bytes": 3, + "one byte": 1, + } { + t.Run(name, func(t *testing.T) { + source := &stubSource{content: "hello world", chunk: chunk} + + payloads := NewFileDropPayloads() + if err := payloads.AddFile("greeting.txt", 11, "text/plain", source); 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 TestPayloadSourceHonoursOffset(t *testing.T) { + source := &stubSource{content: "hello world"} + + payloads := NewFileDropPayloads() + if err := payloads.AddFile("greeting.txt", 11, "", source); 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") + } + if len(source.offsets) != 1 || source.offsets[0] != 6 { + t.Fatalf("expected one open at offset 6, got %v", source.offsets) + } +} + +func TestPayloadRejectsMissingSourceAndOversizedText(t *testing.T) { + payloads := NewFileDropPayloads() + + if err := payloads.AddFile("no-source.bin", 1, "", nil); err == nil { + t.Fatal("expected an error for a file without a source") + } + if err := payloads.AddFile("", 1, "", &stubSource{}); 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() + + if fd.DestinationDir() != "" { + t.Fatalf("expected no destination before seeding, got %q", fd.DestinationDir()) + } + + ensureFileDropDestination(fd) + + 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 TestProfileLocationForSplitsConfigPath(t *testing.T) { + root := t.TempDir() + + dir, id, err := profileLocationFor(filepath.Join(root, defaultConfigFilename)) + if err != nil { + t.Fatalf("default profile: %v", err) + } + if dir != root || id != profilemanager.DefaultProfileName { + t.Fatalf("default profile = (%q, %q), want (%q, %q)", dir, id, root, profilemanager.DefaultProfileName) + } + + named := filepath.Join(root, profilesSubdir, "aaaaaaaabbbbbbbbccccccccdddddddd.json") + dir, id, err = profileLocationFor(named) + if err != nil { + t.Fatalf("named profile: %v", err) + } + if dir != root || id != "aaaaaaaabbbbbbbbccccccccdddddddd" { + t.Fatalf("named profile = (%q, %q), want (%q, %q)", dir, id, root, "aaaaaaaabbbbbbbbccccccccdddddddd") + } + + for _, path := range []string{"", filepath.Join(root, "stray.json"), filepath.Join(root, profilesSubdir, "not-an-id!.json")} { + if _, _, err := profileLocationFor(path); err == nil { + t.Fatalf("expected an error for %q", path) + } + } +} + +func writeTestProfile(t *testing.T, configDir, id string) { + t.Helper() + + pm := NewProfileManager(configDir) + if _, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(id), androidUsername); err != nil { + t.Fatalf("resolve prefs for %s: %v", id, err) + } +} diff --git a/client/android/filedrop_transfer.go b/client/android/filedrop_transfer.go new file mode 100644 index 000000000..5fef54a67 --- /dev/null +++ b/client/android/filedrop_transfer.go @@ -0,0 +1,163 @@ +//go:build android + +package android + +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) +) + +// 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() +} diff --git a/client/android/profile_state.go b/client/android/profile_state.go index 0063b587f..1d8232c2e 100644 --- a/client/android/profile_state.go +++ b/client/android/profile_state.go @@ -48,6 +48,33 @@ func profileAccountPathFor(configPath string) (string, error) { return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil } +// profileLocationFor splits a profile's config path back into the config dir and +// the profile ID: /netbird.cfg is the default profile, while +// /profiles/.json is a named one. +func profileLocationFor(configPath string) (string, string, error) { + if configPath == "" { + return "", "", fmt.Errorf("empty config path") + } + + base := filepath.Base(configPath) + dir := filepath.Dir(configPath) + + if base == defaultConfigFilename { + return dir, profilemanager.DefaultProfileName, nil + } + + if filepath.Base(dir) != profilesSubdir { + return "", "", fmt.Errorf("config path %q is outside the profiles directory", configPath) + } + + id := strings.TrimSuffix(base, filepath.Ext(base)) + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", "", fmt.Errorf("config path %q has no valid profile ID", configPath) + } + + return filepath.Dir(dir), id, nil +} + // readProfileEmail returns the account email stored for the profile whose config // lives at configPath. A missing or unreadable file yields "", which leaves the // account choice to the IdP. diff --git a/client/internal/connect.go b/client/internal/connect.go index e45ecca44..e6196a70f 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -27,6 +27,7 @@ import ( "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/filedrop" "github.com/netbirdio/netbird/client/internal/lazyconn" "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/metrics" @@ -66,10 +67,11 @@ type ConnectClient struct { config *profilemanager.Config statusRecorder *peer.Status - engine *Engine - engineMutex sync.Mutex - clientMetrics *metrics.ClientMetrics - updateManager *updater.Manager + engine *Engine + engineMutex sync.Mutex + clientMetrics *metrics.ClientMetrics + updateManager *updater.Manager + fileDropManager *filedrop.Manager persistSyncResponse bool @@ -125,6 +127,12 @@ func (c *ConnectClient) SetUpdateManager(um *updater.Manager) { c.updateManager = um } +// SetFileDropManager hands the engine the active profile's file drop manager, so +// the transfer receiver starts and stops with the tunnel. Must be set before Run. +func (c *ConnectClient) SetFileDropManager(m *filedrop.Manager) { + c.fileDropManager = m +} + // Run with main logic. func (c *ConnectClient) Run(runningChan chan struct{}, logPath string) error { if androidRunOverride != nil { @@ -463,6 +471,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan UpdateManager: c.updateManager, ClientMetrics: c.clientMetrics, MetricsCtx: c.ctx, + FileDrop: c.fileDropManager, NetState: c.netState, }, mobileDependency) engine.SetSyncResponsePersistence(c.persistSyncResponse) diff --git a/client/internal/engine.go b/client/internal/engine.go index 5380651a5..a8d732118 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -40,6 +40,7 @@ import ( dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" "github.com/netbirdio/netbird/client/internal/dnsfwd" "github.com/netbirdio/netbird/client/internal/expose" + "github.com/netbirdio/netbird/client/internal/filedrop" "github.com/netbirdio/netbird/client/internal/ingressgw" "github.com/netbirdio/netbird/client/internal/lazyconn" "github.com/netbirdio/netbird/client/internal/metrics" @@ -182,6 +183,7 @@ type EngineServices struct { UpdateManager *updater.Manager ClientMetrics *metrics.ClientMetrics MetricsCtx context.Context + FileDrop *filedrop.Manager // NetState gates the reconnection loops on OS-reported network // availability; nil disables gating. NetState *netstate.State @@ -244,6 +246,10 @@ type Engine struct { sshServer sshServer + fileDrop *filedrop.Manager + fileDropRunning bool + fileDropPort uint16 + statusRecorder *peer.Status firewall firewallManager.Manager @@ -359,6 +365,7 @@ func NewEngine( metricsCtx: services.MetricsCtx, updateManager: services.UpdateManager, syncStoreDir: config.StateDir, + fileDrop: services.FileDrop, } // sessionWatcher keeps the SubscribeStatus consumers in sync with the // session expiry deadline. Deadline-change ticks come for free via @@ -424,6 +431,8 @@ func (e *Engine) stopLocked() { log.Warnf("failed to stop SSH server: %v", err) } + e.stopFileDrop() + e.cleanupSSHConfig() if e.ingressGatewayMgr != nil { @@ -1302,6 +1311,8 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { } } + e.startFileDrop() + state := e.statusRecorder.GetLocalPeerState() state.IP = e.wgInterface.Address().String() state.IPv6 = e.wgInterface.Address().IPv6String() @@ -1970,6 +1981,8 @@ func (e *Engine) receiveSignalEvents() error { return err } + e.recordFiledropPort(msg.Key, msg.GetBody().GetFiledropPort()) + log.Debugf("receiveMSG: took %s to get lock for peer %s with session id %s", gotLock, msg.Key, offerAnswer.SessionID) if msg.Body.Type == sProto.Body_OFFER { diff --git a/client/internal/engine_filedrop.go b/client/internal/engine_filedrop.go new file mode 100644 index 000000000..50b49f209 --- /dev/null +++ b/client/internal/engine_filedrop.go @@ -0,0 +1,127 @@ +package internal + +import ( + "context" + "net" + "net/netip" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/filedrop" + nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" + "github.com/netbirdio/netbird/client/internal/peer" +) + +type filedropResolver struct { + status *peer.Status +} + +// ResolvePeer implements filedrop.PeerResolver. +func (r filedropResolver) ResolvePeer(addr netip.Addr) (filedrop.PeerKey, string, bool) { + state, ok := r.status.PeerStateByIP(addr.String()) + if !ok { + return "", "", false + } + return filedrop.PeerKey(state.PubKey), state.FQDN, true +} + +func (e *Engine) startFileDrop() { + if e.fileDrop == nil || e.fileDropRunning || e.wgInterface == nil { + return + } + if e.config.BlockInbound { + log.Info("file drop receiver is disabled because inbound connections are blocked") + e.setFileDropTunnel() + return + } + + wgAddr := e.wgInterface.Address() + addr := netip.AddrPortFrom(wgAddr.IP, filedrop.Port) + resolver := filedropResolver{status: e.statusRecorder} + + netstackNet := e.wgInterface.GetNet() + if err := e.fileDrop.StartReceiver(e.ctx, addr, netstackNet, resolver); err != nil { + log.Errorf("failed to start file drop receiver: %v", err) + return + } + + bound := e.fileDrop.ReceiverPort() + if bound == 0 { + bound = filedrop.Port + } + e.fileDropPort = bound + + if v6 := wgAddr.IPv6; v6.IsValid() { + if err := e.fileDrop.AddReceiverListener(e.ctx, netip.AddrPortFrom(v6, bound)); err != nil { + log.Warnf("failed to add IPv6 file drop listener: %v", err) + } + } + + if netstackNet != nil { + if registrar, ok := e.firewall.(interface { + RegisterNetstackService(protocol nftypes.Protocol, port uint16) + }); ok { + registrar.RegisterNetstackService(nftypes.TCP, bound) + } + } + + if bound != filedrop.Port { + e.signaler.SetFiledropPort(bound) + } + + e.setFileDropTunnel() + e.fileDropRunning = true +} + +// recordFiledropPort stores the file drop port a peer advertised over signaling; +// a value that does not fit a port is treated as the default. +func (e *Engine) recordFiledropPort(peerKey string, port uint32) { + if e.fileDrop == nil { + return + } + if port > 65535 { + port = 0 + } + e.fileDrop.Ports().Set(filedrop.PeerKey(peerKey), uint16(port)) +} + +func (e *Engine) setFileDropTunnel() { + var dial filedrop.DialFunc + if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { + dial = func(ctx context.Context, _, addr string) (net.Conn, error) { + addrPort, err := netip.ParseAddrPort(addr) + if err != nil { + return nil, err + } + return netstackNet.DialContextTCPAddrPort(ctx, addrPort) + } + } else { + dialer := &net.Dialer{} + dial = dialer.DialContext + } + + e.fileDrop.SetTunnel(dial, e.statusRecorder.GetLocalPeerState().FQDN) +} + +func (e *Engine) stopFileDrop() { + if e.fileDrop == nil { + return + } + + if e.fileDropRunning { + if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { + if registrar, ok := e.firewall.(interface { + UnregisterNetstackService(protocol nftypes.Protocol, port uint16) + }); ok { + registrar.UnregisterNetstackService(nftypes.TCP, e.fileDropPort) + } + } + e.signaler.SetFiledropPort(0) + } + + if err := e.fileDrop.StopReceiver(); err != nil { + log.Warnf("failed to stop file drop receiver: %v", err) + } + e.fileDropRunning = false + e.fileDropPort = 0 +} diff --git a/client/internal/filedrop/client.go b/client/internal/filedrop/client.go new file mode 100644 index 000000000..2bb909089 --- /dev/null +++ b/client/internal/filedrop/client.go @@ -0,0 +1,446 @@ +package filedrop + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "strconv" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + defaultPollTimeout = 60 * time.Second + defaultOfferTimeout = DefaultOfferTTL + uploadRetryDelay = 2 * time.Second + maxUploadAttempts = 3 +) + +// DialFunc opens a connection to the receiving peer over the tunnel. +type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error) + +// Payload is one item to send; Open is called per attempt starting at an offset. +type Payload struct { + Meta FileMeta + Open func(offset int64) (io.ReadCloser, error) +} + +// ProgressFunc reports staged bytes for one item as the upload streams. +type ProgressFunc func(index int, sent int64, total int64) + +// ClientConfig configures the sending side. +type ClientConfig struct { + Dial DialFunc + SenderName string + PollTimeout time.Duration + OfferTimeout time.Duration +} + +type progressReader struct { + r io.Reader + sent int64 + total int64 + report func(sent int64) +} + +// Client sends offers and payloads to a peer's file drop service. +type Client struct { + http *http.Client + senderName string + pollTimeout time.Duration + offerTimeout time.Duration +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + if n > 0 { + p.sent += int64(n) + p.report(p.sent) + } + return n, err +} + +// NewClient builds a sending client over the given dialer. +func NewClient(cfg ClientConfig) (*Client, error) { + if cfg.Dial == nil { + return nil, errors.New("dial function is required") + } + + pollTimeout := cfg.PollTimeout + if pollTimeout <= 0 { + pollTimeout = defaultPollTimeout + } + offerTimeout := cfg.OfferTimeout + if offerTimeout <= 0 { + offerTimeout = defaultOfferTimeout + } + + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return cfg.Dial(ctx, network, addr) }, + MaxIdleConnsPerHost: 2, + ResponseHeaderTimeout: pollTimeout + 30*time.Second, + } + + return &Client{ + http: &http.Client{Transport: transport}, + senderName: cfg.SenderName, + pollTimeout: pollTimeout, + offerTimeout: offerTimeout, + }, nil +} + +// TextPayload builds an inline text payload, which is carried in the offer itself. +func TextPayload(name, text string) Payload { + return Payload{ + Meta: FileMeta{ + Name: name, + Size: int64(len(text)), + ContentType: "text/plain", + Kind: KindText, + Text: text, + }, + } +} + +// Send offers the payloads to the peer at addr and uploads them once accepted. +func (c *Client) Send(ctx context.Context, addr netip.AddrPort, payloads []Payload, progress ProgressFunc) (OfferID, error) { + id, decision, err := c.Offer(ctx, addr, payloads) + if err != nil { + return id, err + } + + decision, err = c.AwaitDecision(ctx, addr, id, decision) + if err != nil { + return id, err + } + if err := decisionError(decision); err != nil { + return id, err + } + + return id, c.Upload(ctx, addr, id, payloads, progress) +} + +// Offer announces the payloads and returns the offer ID with its initial decision. +func (c *Client) Offer(ctx context.Context, addr netip.AddrPort, payloads []Payload) (OfferID, Decision, error) { + if len(payloads) == 0 { + return "", DecisionPending, fmt.Errorf("%w: no payloads", ErrInvalidOffer) + } + return c.postOffer(ctx, baseURL(addr), payloads) +} + +// AwaitDecision resolves a pending decision by long-polling the receiver. +func (c *Client) AwaitDecision(ctx context.Context, addr netip.AddrPort, id OfferID, decision Decision) (Decision, error) { + if decision != DecisionPending { + return decision, nil + } + return c.awaitDecision(ctx, baseURL(addr), id) +} + +// Upload streams every non-inline payload of an accepted offer. +func (c *Client) Upload(ctx context.Context, addr netip.AddrPort, id OfferID, payloads []Payload, progress ProgressFunc) error { + base := baseURL(addr) + for i, p := range payloads { + if p.Meta.Kind == KindText { + continue + } + if err := c.uploadFile(ctx, base, id, i, p, progress); err != nil { + return fmt.Errorf("upload %s: %w", p.Meta.Name, err) + } + } + return nil +} + +// Cancel withdraws an offer, taking the receiver's consent prompt with it. +func (c *Client) Cancel(ctx context.Context, addr netip.AddrPort, id OfferID) error { + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, offerURL(baseURL(addr), id), nil) + if err != nil { + return fmt.Errorf("build cancel request: %w", err) + } + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("send cancel: %w", err) + } + defer drainAndClose(resp) + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound { + return statusError(resp) + } + return nil +} + +func (c *Client) postOffer(ctx context.Context, base string, payloads []Payload) (OfferID, Decision, error) { + files := make([]FileMeta, len(payloads)) + for i, p := range payloads { + files[i] = p.Meta + } + + body, err := json.Marshal(OfferRequest{SenderName: c.senderName, Files: files}) + if err != nil { + return "", DecisionPending, fmt.Errorf("encode offer: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+pathOffers, bytes.NewReader(body)) + if err != nil { + return "", DecisionPending, fmt.Errorf("build offer request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return "", DecisionPending, fmt.Errorf("send offer: %w", err) + } + defer drainAndClose(resp) + + switch resp.StatusCode { + case http.StatusCreated, http.StatusAccepted: + case http.StatusForbidden: + return "", DecisionPending, ErrRefused + default: + return "", DecisionPending, statusError(resp) + } + + var offer OfferResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, maxOfferBodySize)).Decode(&offer); err != nil { + return "", DecisionPending, fmt.Errorf("decode offer response: %w", err) + } + if offer.ID == "" { + return "", DecisionPending, fmt.Errorf("%w: receiver returned no offer id", ErrInvalidOffer) + } + if !offer.Decision.valid() { + return "", DecisionPending, fmt.Errorf("%w: receiver returned decision %s", ErrInvalidOffer, offer.Decision) + } + + return offer.ID, offer.Decision, nil +} + +func (c *Client) awaitDecision(ctx context.Context, base string, id OfferID) (Decision, error) { + deadline := time.Now().Add(c.offerTimeout) + + for time.Now().Before(deadline) { + decision, err := c.pollDecision(ctx, base, id) + if err != nil { + if ctx.Err() != nil { + return DecisionPending, ctx.Err() + } + log.Debugf("poll file drop decision: %v", err) + if !sleepCtx(ctx, uploadRetryDelay) { + return DecisionPending, ctx.Err() + } + continue + } + if decision != DecisionPending { + return decision, nil + } + } + + return DecisionExpired, nil +} + +func (c *Client) pollDecision(ctx context.Context, base string, id OfferID) (Decision, error) { + pollCtx, cancel := context.WithTimeout(ctx, c.pollTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(pollCtx, http.MethodGet, offerURL(base, id), nil) + if err != nil { + return DecisionPending, fmt.Errorf("build status request: %w", err) + } + + resp, err := c.http.Do(req) + if err != nil { + return DecisionPending, fmt.Errorf("poll status: %w", err) + } + defer drainAndClose(resp) + + if resp.StatusCode == http.StatusNotFound { + return DecisionPending, ErrOfferNotFound + } + if resp.StatusCode != http.StatusOK { + return DecisionPending, statusError(resp) + } + + var offer OfferResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, maxOfferBodySize)).Decode(&offer); err != nil { + return DecisionPending, fmt.Errorf("decode status response: %w", err) + } + if !offer.Decision.valid() { + return DecisionPending, fmt.Errorf("%w: receiver returned decision %s", ErrInvalidOffer, offer.Decision) + } + return offer.Decision, nil +} + +func (c *Client) uploadFile(ctx context.Context, base string, id OfferID, index int, p Payload, progress ProgressFunc) error { + var lastErr error + + for attempt := range maxUploadAttempts { + offset := int64(0) + if attempt > 0 { + if !sleepCtx(ctx, uploadRetryDelay) { + return ctx.Err() + } + confirmed, err := c.confirmedOffset(ctx, base, id, index) + if err != nil { + lastErr = err + continue + } + offset = confirmed + } + + if offset >= p.Meta.Size { + return nil + } + + if err := c.putFile(ctx, base, id, index, p, offset, progress); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + lastErr = err + log.Debugf("upload attempt %d for %s: %v", attempt+1, p.Meta.Name, err) + continue + } + return nil + } + + return lastErr +} + +func (c *Client) putFile(ctx context.Context, base string, id OfferID, index int, p Payload, offset int64, progress ProgressFunc) error { + if p.Open == nil { + return fmt.Errorf("payload %s has no reader", p.Meta.Name) + } + + body, err := p.Open(offset) + if err != nil { + return fmt.Errorf("open payload: %w", err) + } + defer func() { + if err := body.Close(); err != nil { + log.Debugf("close payload reader: %v", err) + } + }() + + var reader io.Reader = body + if progress != nil { + reader = &progressReader{ + r: body, + sent: offset, + total: p.Meta.Size, + report: func(sent int64) { + progress(index, sent, p.Meta.Size) + }, + } + } + + url := fileURL(base, id, index) + "?offset=" + strconv.FormatInt(offset, 10) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, reader) + if err != nil { + return fmt.Errorf("build upload request: %w", err) + } + req.ContentLength = p.Meta.Size - offset + req.Header.Set("Content-Type", contentTypeOrDefault(p.Meta.ContentType)) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("send payload: %w", err) + } + defer drainAndClose(resp) + + if resp.StatusCode == http.StatusForbidden { + return ErrNotAccepted + } + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return statusError(resp) + } + return nil +} + +func (c *Client) confirmedOffset(ctx context.Context, base string, id OfferID, index int) (int64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, fileURL(base, id, index), nil) + if err != nil { + return 0, fmt.Errorf("build probe request: %w", err) + } + + resp, err := c.http.Do(req) + if err != nil { + return 0, fmt.Errorf("probe upload: %w", err) + } + defer drainAndClose(resp) + + if resp.StatusCode != http.StatusOK { + return 0, statusError(resp) + } + + raw := resp.Header.Get(HeaderReceivedBytes) + if raw == "" { + return 0, nil + } + offset, err := strconv.ParseInt(raw, 10, 64) + if err != nil || offset < 0 { + return 0, fmt.Errorf("invalid %s header %q", HeaderReceivedBytes, raw) + } + return offset, nil +} + +func baseURL(addr netip.AddrPort) string { + return "http://" + net.JoinHostPort(addr.Addr().Unmap().String(), strconv.Itoa(int(addr.Port()))) +} + +func offerURL(base string, id OfferID) string { + return base + pathOffersSlash + string(id) +} + +func fileURL(base string, id OfferID, index int) string { + return offerURL(base, id) + "/" + segmentFiles + "/" + strconv.Itoa(index) +} + +func contentTypeOrDefault(ct string) string { + if ct == "" { + return "application/octet-stream" + } + return ct +} + +func statusError(resp *http.Response) error { + return fmt.Errorf("receiver returned %s", resp.Status) +} + +func drainAndClose(resp *http.Response) { + if _, err := io.Copy(io.Discard, io.LimitReader(resp.Body, maxOfferBodySize)); err != nil { + log.Tracef("drain response body: %v", err) + } + if err := resp.Body.Close(); err != nil { + log.Debugf("close response body: %v", err) + } +} + +func sleepCtx(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-timer.C: + return true + case <-ctx.Done(): + return false + } +} + +func decisionError(decision Decision) error { + switch decision { + case DecisionAccepted: + return nil + case DecisionDeclined: + return ErrDeclined + case DecisionExpired: + return ErrExpired + default: + return fmt.Errorf("unexpected decision %s", decision) + } +} diff --git a/client/internal/filedrop/delivery.go b/client/internal/filedrop/delivery.go new file mode 100644 index 000000000..19126b8d6 --- /dev/null +++ b/client/internal/filedrop/delivery.go @@ -0,0 +1,100 @@ +package filedrop + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + log "github.com/sirupsen/logrus" +) + +func deliver(spool *Spool, offer Offer, destDir string) ([]string, error) { + if destDir == "" { + return nil, fmt.Errorf("no destination directory configured") + } + if err := os.MkdirAll(destDir, 0o755); err != nil { + return nil, fmt.Errorf("create destination dir: %w", err) + } + + var delivered []string + for i, f := range offer.Files { + if f.Kind == KindText { + continue + } + + dest, err := moveToUniqueName(spool.Path(offer.ID, i), destDir, sanitizeFileName(f.Name, i)) + if err != nil { + return delivered, fmt.Errorf("deliver %s: %w", f.Name, err) + } + if err := chownToDirOwner(dest, destDir); err != nil { + log.Debugf("failed to adopt owner for %s: %v", dest, err) + } + delivered = append(delivered, dest) + } + + spool.Remove(offer.ID) + return delivered, nil +} + +func sanitizeFileName(name string, index int) string { + name = filepath.Base(filepath.Clean(strings.ReplaceAll(name, "\\", "/"))) + if name == "" || name == "." || name == ".." || name == string(filepath.Separator) { + return fmt.Sprintf("file-%d", index) + } + return name +} + +func moveToUniqueName(src, dir, name string) (string, error) { + ext := filepath.Ext(name) + stem := strings.TrimSuffix(name, ext) + + for attempt := 0; attempt < 1000; attempt++ { + candidate := name + if attempt > 0 { + candidate = fmt.Sprintf("%s (%d)%s", stem, attempt, ext) + } + dest := filepath.Join(dir, candidate) + + f, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil { + if os.IsExist(err) { + continue + } + return "", fmt.Errorf("create destination: %w", err) + } + + if err := moveInto(f, src); err != nil { + _ = f.Close() + _ = os.Remove(dest) + return "", err + } + if err := f.Close(); err != nil { + return "", fmt.Errorf("close destination: %w", err) + } + if err := os.Remove(src); err != nil { + log.Debugf("failed to remove spooled source %s: %v", src, err) + } + return dest, nil + } + + return "", fmt.Errorf("no free name for %s in %s", name, dir) +} + +func moveInto(dst *os.File, src string) error { + s, err := os.Open(src) + if err != nil { + return fmt.Errorf("open spooled file: %w", err) + } + defer func() { + if err := s.Close(); err != nil { + log.Debugf("close spooled file: %v", err) + } + }() + + if _, err := io.Copy(dst, s); err != nil { + return fmt.Errorf("copy payload: %w", err) + } + return nil +} diff --git a/client/internal/filedrop/delivery_other.go b/client/internal/filedrop/delivery_other.go new file mode 100644 index 000000000..1a077f180 --- /dev/null +++ b/client/internal/filedrop/delivery_other.go @@ -0,0 +1,7 @@ +//go:build windows || js + +package filedrop + +func chownToDirOwner(string, string) error { + return nil +} diff --git a/client/internal/filedrop/delivery_unix.go b/client/internal/filedrop/delivery_unix.go new file mode 100644 index 000000000..1baa3bee4 --- /dev/null +++ b/client/internal/filedrop/delivery_unix.go @@ -0,0 +1,29 @@ +//go:build !windows && !js + +package filedrop + +import ( + "fmt" + "os" + "syscall" +) + +func chownToDirOwner(path, dir string) error { + info, err := os.Stat(dir) + if err != nil { + return fmt.Errorf("stat destination dir: %w", err) + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return nil + } + if os.Geteuid() != 0 || int(stat.Uid) == os.Geteuid() { + return nil + } + + if err := os.Chown(path, int(stat.Uid), int(stat.Gid)); err != nil { + return fmt.Errorf("chown delivered file: %w", err) + } + return nil +} diff --git a/client/internal/filedrop/filedrop_test.go b/client/internal/filedrop/filedrop_test.go new file mode 100644 index 000000000..710b75a7c --- /dev/null +++ b/client/internal/filedrop/filedrop_test.go @@ -0,0 +1,850 @@ +package filedrop + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/netip" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +const ( + testPeer = PeerKey("peer-pubkey") + testProfile = profilemanager.ID("test-profile") +) + +type staticResolver struct { + key PeerKey + name string + unknown bool +} + +func (r staticResolver) ResolvePeer(netip.Addr) (PeerKey, string, bool) { + if r.unknown { + return "", "", false + } + return r.key, r.name, true +} + +type recordingNotifier struct { + mu sync.Mutex + offers []Offer + completed []Offer + failed []Offer + withdrawn []Offer + progress int +} + +func (n *recordingNotifier) OnOffer(o Offer) { + n.mu.Lock() + defer n.mu.Unlock() + n.offers = append(n.offers, o) +} + +func (n *recordingNotifier) OnProgress(Offer, int, int64) { + n.mu.Lock() + defer n.mu.Unlock() + n.progress++ +} + +func (n *recordingNotifier) OnCompleted(o Offer) { + n.mu.Lock() + defer n.mu.Unlock() + n.completed = append(n.completed, o) +} + +func (n *recordingNotifier) OnFailed(o Offer, _ error) { + n.mu.Lock() + defer n.mu.Unlock() + n.failed = append(n.failed, o) +} + +func (n *recordingNotifier) OnWithdrawn(o Offer) { + n.mu.Lock() + defer n.mu.Unlock() + n.withdrawn = append(n.withdrawn, o) +} + +func (n *recordingNotifier) snapshot() (offers, completed, failed, withdrawn []Offer) { + n.mu.Lock() + defer n.mu.Unlock() + return append([]Offer(nil), n.offers...), append([]Offer(nil), n.completed...), + append([]Offer(nil), n.failed...), append([]Offer(nil), n.withdrawn...) +} + +func startTestServer(t *testing.T, mode Mode, resolver PeerResolver) (*Server, *Client, *recordingNotifier) { + t.Helper() + + policy := NewPolicyStore(testProfile) + require.NoError(t, policy.Set(Policy{Mode: mode})) + + notifier := &recordingNotifier{} + srv, err := NewServer(ServerConfig{ + SpoolDir: t.TempDir(), + Policy: policy, + Resolver: resolver, + Notifier: notifier, + OfferTTL: 5 * time.Second, + }) + require.NoError(t, err, "server setup must succeed") + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + require.NoError(t, srv.Start(ctx, netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0))) + t.Cleanup(func() { + require.NoError(t, srv.Stop()) + }) + + srv.mu.RLock() + addr := srv.listener.Addr().String() + srv.mu.RUnlock() + + client, err := NewClient(ClientConfig{ + SenderName: "sender", + PollTimeout: 2 * time.Second, + OfferTimeout: 5 * time.Second, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) + }, + }) + require.NoError(t, err, "client setup must succeed") + + return srv, client, notifier +} + +func filePayload(t *testing.T, name string, content []byte) Payload { + t.Helper() + + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, content, 0o600)) + + return Payload{ + Meta: FileMeta{Name: name, Size: int64(len(content)), ContentType: "application/octet-stream"}, + Open: func(offset int64) (io.ReadCloser, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + if _, err := f.Seek(offset, io.SeekStart); err != nil { + _ = f.Close() + return nil, err + } + return f, nil + }, + } +} + +var testAddr = netip.AddrPortFrom(netip.AddrFrom4([4]byte{100, 64, 0, 1}), Port) + +func TestAutoAcceptTransfersPayload(t *testing.T) { + srv, client, notifier := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer, name: "laptop"}) + + content := []byte(strings.Repeat("netbird", 1000)) + payload := filePayload(t, "report.bin", content) + + var lastSent int64 + id, err := client.Send(context.Background(), testAddr, []Payload{payload}, func(_ int, sent, _ int64) { + lastSent = sent + }) + require.NoError(t, err) + require.NotEmpty(t, id, "receiver must return an offer id") + + assert.Equal(t, int64(len(content)), lastSent, "progress must reach the full payload size") + + staged, err := os.ReadFile(srv.Spool().Path(id, 0)) + require.NoError(t, err) + assert.Equal(t, content, staged, "staged bytes should match what was sent") + + offer, ok := srv.Offers().Get(testPeer, id) + require.True(t, ok, "offer must still be tracked") + assert.Equal(t, StateCompleted, offer.State, "offer should be completed") + assert.Equal(t, "laptop", offer.SenderName, "sender name should come from the resolver") + + _, completed, _, _ := notifier.snapshot() + require.Len(t, completed, 1, "one completion event expected") + assert.Equal(t, id, completed[0].ID) +} + +func TestAskModeAcceptReleasesUpload(t *testing.T) { + srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer}) + + content := []byte("consent required") + payload := filePayload(t, "note.txt", content) + + go func() { + for { + offers, _, _, _ := notifier.snapshot() + if len(offers) > 0 { + srv.Offers().Decide(offers[0].ID, DecisionAccepted) + return + } + time.Sleep(10 * time.Millisecond) + } + }() + + id, err := client.Send(context.Background(), testAddr, []Payload{payload}, nil) + require.NoError(t, err) + + staged, err := os.ReadFile(srv.Spool().Path(id, 0)) + require.NoError(t, err) + assert.Equal(t, content, staged, "payload should arrive after acceptance") + + offers, _, _, _ := notifier.snapshot() + require.Len(t, offers, 1, "the pending offer must be raised exactly once") + assert.Equal(t, DecisionPending, offers[0].Decision, "the raised offer starts pending") +} + +func TestAskModeDeclineKeepsPayloadOut(t *testing.T) { + srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer}) + + go func() { + for { + offers, _, _, _ := notifier.snapshot() + if len(offers) > 0 { + srv.Offers().Decide(offers[0].ID, DecisionDeclined) + return + } + time.Sleep(10 * time.Millisecond) + } + }() + + id, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil) + require.ErrorIs(t, err, ErrDeclined, "sender must see the decline") + + _, statErr := os.Stat(srv.Spool().Path(id, 0)) + assert.True(t, os.IsNotExist(statErr), "declined payload must never be staged") +} + +func TestOffModeRefusesOffer(t *testing.T) { + _, client, notifier := startTestServer(t, ModeOff, staticResolver{key: testPeer}) + + _, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil) + require.ErrorIs(t, err, ErrRefused, "an off receiver must refuse the offer") + + offers, _, _, _ := notifier.snapshot() + assert.Empty(t, offers, "a refused offer must not reach the user") +} + +func TestUnknownSenderIsRefused(t *testing.T) { + _, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{unknown: true}) + + _, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil) + require.ErrorIs(t, err, ErrRefused, "an unresolvable source address must be refused") +} + +func TestUploadResumesFromConfirmedOffset(t *testing.T) { + srv, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer}) + + content := []byte(strings.Repeat("resume", 500)) + payload := filePayload(t, "big.bin", content) + + offer := srv.Offers().Add(testPeer, "", []FileMeta{payload.Meta}, DecisionAccepted) + require.NoError(t, srv.Spool().Prepare(offer.ID)) + + half := int64(len(content) / 2) + _, err := srv.Spool().Write(offer.ID, 0, 0, strings.NewReader(string(content[:half])), half) + require.NoError(t, err) + + srv.mu.RLock() + base := "http://" + srv.listener.Addr().String() + srv.mu.RUnlock() + + confirmed, err := client.confirmedOffset(context.Background(), base, offer.ID, 0) + require.NoError(t, err) + require.Equal(t, half, confirmed, "receiver must report the staged prefix") + + require.NoError(t, client.putFile(context.Background(), base, offer.ID, 0, payload, confirmed, nil)) + + staged, err := os.ReadFile(srv.Spool().Path(offer.ID, 0)) + require.NoError(t, err) + assert.Equal(t, content, staged, "resumed upload must reconstruct the full payload") +} + +func TestUploadIsBoundedByAnnouncedSize(t *testing.T) { + srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer}) + + // The request is issued raw: the stdlib client refuses to send a body that + announced := int64(10) + offer := srv.Offers().Add(testPeer, "", []FileMeta{{Name: "lie.bin", Size: announced}}, DecisionAccepted) + require.NoError(t, srv.Spool().Prepare(offer.ID)) + + srv.mu.RLock() + addr := srv.listener.Addr().String() + srv.mu.RUnlock() + + conn, err := net.Dial("tcp", addr) + require.NoError(t, err) + defer func() { + _ = conn.Close() + }() + + oversized := strings.Repeat("A", 100) + request := "PUT /v1/offers/" + string(offer.ID) + "/files/0?offset=0 HTTP/1.1\r\n" + + "Host: filedrop\r\nContent-Length: 100\r\nConnection: close\r\n\r\n" + oversized + _, err = conn.Write([]byte(request)) + require.NoError(t, err) + + _, err = io.ReadAll(conn) + require.NoError(t, err) + + staged, err := os.ReadFile(srv.Spool().Path(offer.ID, 0)) + require.NoError(t, err) + assert.Len(t, staged, int(announced), "staged size must be capped at the announced size") +} + +func TestCancelWithdrawsPendingOffer(t *testing.T) { + srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer}) + + sendCtx, cancelSend := context.WithCancel(context.Background()) + defer cancelSend() + + go func() { + _, _ = client.Send(sendCtx, testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil) + }() + + var id OfferID + require.Eventually(t, func() bool { + offers, _, _, _ := notifier.snapshot() + if len(offers) == 0 { + return false + } + id = offers[0].ID + return true + }, 3*time.Second, 10*time.Millisecond, "offer must reach the receiver") + + require.NoError(t, client.Cancel(context.Background(), testAddr, id)) + + _, ok := srv.Offers().Get(testPeer, id) + assert.False(t, ok, "a withdrawn offer must be dropped") + + _, _, _, withdrawn := notifier.snapshot() + require.Len(t, withdrawn, 1, "the consent prompt must be withdrawn") + assert.Equal(t, id, withdrawn[0].ID) +} + +func TestTextPayloadStaysInline(t *testing.T) { + srv, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer}) + + id, err := client.Send(context.Background(), testAddr, []Payload{TextPayload("snippet", "hello peer")}, nil) + require.NoError(t, err) + + offer, ok := srv.Offers().Get(testPeer, id) + require.True(t, ok) + require.Len(t, offer.Files, 1) + assert.Equal(t, "hello peer", offer.Files[0].Text, "text must arrive in the offer itself") + assert.Equal(t, StateCompleted, offer.State, "a text-only offer completes without an upload") + + _, statErr := os.Stat(srv.Spool().Path(id, 0)) + assert.True(t, os.IsNotExist(statErr), "text payloads must not be written to the spool") +} + +func TestOfferExpiresWithoutDecision(t *testing.T) { + policy := NewPolicyStore(testProfile) + require.NoError(t, policy.SetMode(ModeAsk)) + + srv, err := NewServer(ServerConfig{ + SpoolDir: t.TempDir(), + Policy: policy, + Resolver: staticResolver{key: testPeer}, + OfferTTL: 100 * time.Millisecond, + }) + require.NoError(t, err) + + offer := srv.Offers().Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionPending) + + awaited, err := srv.Offers().Await(context.Background(), testPeer, offer.ID) + require.NoError(t, err) + assert.Equal(t, DecisionExpired, awaited.Decision, "an unanswered offer must expire") + assert.Equal(t, StateExpired, awaited.State) +} + +func TestDecideIsFinal(t *testing.T) { + store := NewOfferStore(time.Minute) + offer := store.Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionPending) + + _, ok := store.Decide(offer.ID, DecisionDeclined) + require.True(t, ok, "the first decision must be recorded") + + _, ok = store.Decide(offer.ID, DecisionAccepted) + assert.False(t, ok, "a decided offer must not be revived") + + current, ok := store.Get(testPeer, offer.ID) + require.True(t, ok) + assert.Equal(t, DecisionDeclined, current.Decision, "the original decision must stand") +} + +func TestOfferIsScopedToItsSender(t *testing.T) { + store := NewOfferStore(time.Minute) + offer := store.Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionAccepted) + + _, ok := store.Get("other-peer", offer.ID) + assert.False(t, ok, "another peer must not see the offer") + + _, err := store.Await(context.Background(), "other-peer", offer.ID) + assert.ErrorIs(t, err, ErrOfferNotFound, "another peer must not poll the offer") +} + +func TestPolicyEvaluation(t *testing.T) { + store := NewPolicyStore(testProfile) + require.NoError(t, store.Set(Policy{Mode: ModeAsk})) + + assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "unknown senders are asked about") + + require.NoError(t, store.SetSenderRule(testPeer, SenderRuleBlock)) + assert.Equal(t, ModeOff, store.Evaluate(testPeer), "a blocked sender is refused") + + require.NoError(t, store.SetSenderRule(testPeer, SenderRuleAlwaysAccept)) + assert.Equal(t, ModeAutoAccept, store.Evaluate(testPeer), "an always-accept sender skips the prompt") + + require.NoError(t, store.SetSenderRule(testPeer, SenderRuleDefault)) + assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "clearing the rule restores the base mode") +} + +func TestPolicyRejectsUnknownModeAndDeniesOnCorruptRule(t *testing.T) { + store := NewPolicyStore(testProfile) + + require.Error(t, store.SetMode(Mode(200)), "an unknown mode must be rejected") + assert.Equal(t, ModeAsk, store.Get().Mode, "the rejected mode must not be applied") + + require.NoError(t, store.SetSenderRule(testPeer, SenderRule(200))) + assert.Equal(t, ModeOff, store.Evaluate(testPeer), "an unrecognized rule must deny") +} + +type memStore struct { + mu sync.Mutex + sections map[string][]byte + loadErr error +} + +func newMemStore() *memStore { + return &memStore{sections: map[string][]byte{}} +} + +func (s *memStore) Get(namespace string, v any) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.loadErr != nil { + return false, s.loadErr + } + raw, ok := s.sections[namespace] + if !ok { + return false, nil + } + return true, json.Unmarshal(raw, v) +} + +func (s *memStore) Put(namespace string, v any) error { + raw, err := json.Marshal(v) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + s.sections[namespace] = raw + return nil +} + +func TestPolicyIsScopedPerProfile(t *testing.T) { + work, home := profilemanager.ID("work"), profilemanager.ID("home") + workPrefs, homePrefs := newMemStore(), newMemStore() + + workStore := LoadPolicyStore(work, workPrefs) + require.NoError(t, workStore.SetMode(ModeOff)) + require.NoError(t, workStore.SetSenderRule(testPeer, SenderRuleBlock)) + + homeStore := LoadPolicyStore(home, homePrefs) + assert.Equal(t, ModeAsk, homeStore.Get().Mode, "another profile keeps the default mode") + assert.Equal(t, ModeAsk, homeStore.Evaluate(testPeer), "a block in one profile must not apply to another") + + reloaded := LoadPolicyStore(work, workPrefs) + assert.Equal(t, ModeOff, reloaded.Get().Mode, "the profile's mode must survive a reload") + assert.Equal(t, ModeOff, reloaded.Evaluate(testPeer), "the profile's sender rule must survive a reload") +} + +func TestPolicyFallsBackToDefaultsOnLoadFailure(t *testing.T) { + prefs := newMemStore() + prefs.loadErr = errors.New("store unavailable") + + store := LoadPolicyStore(testProfile, prefs) + + assert.Equal(t, ModeAsk, store.Get().Mode, "an unreadable policy must not open the device up") + assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "the safe default applies to unknown senders") +} + +func TestPolicyRejectsStoredInvalidModeOnLoad(t *testing.T) { + prefs := newMemStore() + require.NoError(t, prefs.Put(namespacePolicy, Policy{Mode: Mode(200)})) + + store := LoadPolicyStore(testProfile, prefs) + + assert.Equal(t, ModeAsk, store.Get().Mode, "a corrupted stored mode must fall back to the default") +} + +func TestStoreKeepsPolicyAndHistoryApart(t *testing.T) { + prefs := newMemStore() + + mgr, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir(), Store: prefs}) + require.NoError(t, err) + require.NoError(t, mgr.Policy().SetMode(ModeAutoAccept)) + require.NoError(t, mgr.SetDestinationDir("/tmp/received")) + mgr.history.Upsert(Transfer{ID: "offer-1", PeerKey: testPeer, State: StateCompleted}) + require.NoError(t, mgr.Close()) + + reloaded, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir(), Store: prefs}) + require.NoError(t, err) + defer func() { require.NoError(t, reloaded.Close()) }() + + assert.Equal(t, ModeAutoAccept, reloaded.Policy().Get().Mode, "the policy must survive a reload") + assert.Equal(t, "/tmp/received", reloaded.DestinationDir(), "the destination must survive a reload") + require.Len(t, reloaded.Transfers(), 1, "the history must survive a reload") + assert.Equal(t, OfferID("offer-1"), reloaded.Transfers()[0].ID) +} + +func TestHistoryDropsOldestTerminalEntriesOverCap(t *testing.T) { + history := LoadHistory(newMemStore()) + + for i := 0; i < historyCap+10; i++ { + history.Upsert(Transfer{ID: OfferID(fmt.Sprintf("offer-%d", i)), State: StateCompleted}) + } + + entries := history.List() + require.Len(t, entries, historyCap, "the log must stay bounded") + assert.Equal(t, OfferID(fmt.Sprintf("offer-%d", historyCap+9)), entries[0].ID, "the newest entry stays") +} + +func TestHistoryKeepsLiveTransfersOverCap(t *testing.T) { + history := LoadHistory(newMemStore()) + + history.Upsert(Transfer{ID: "live", State: StateTransferring}) + for i := 0; i < historyCap+5; i++ { + history.Upsert(Transfer{ID: OfferID(fmt.Sprintf("done-%d", i)), State: StateCompleted}) + } + + _, ok := history.Get("live") + assert.True(t, ok, "a transfer still running must not be pruned") +} + +func TestHistorySettlesTransfersInterruptedByRestart(t *testing.T) { + store := newMemStore() + + history := LoadHistory(store) + history.Upsert(Transfer{ID: "pending", State: StatePending}) + history.Upsert(Transfer{ID: "moving", State: StateTransferring}) + history.Upsert(Transfer{ID: "done", State: StateCompleted}) + history.Upsert(Transfer{ID: "refused", State: StateDeclined}) + + // A fresh load stands in for the next process: nothing survives to finish + // whatever was still moving. + reloaded := LoadHistory(store) + + for _, tc := range []struct { + id OfferID + state State + reason FailureReason + }{ + {"pending", StateFailed, ReasonInterrupted}, + {"moving", StateFailed, ReasonInterrupted}, + {"done", StateCompleted, ReasonNone}, + {"refused", StateDeclined, ReasonNone}, + } { + entry, ok := reloaded.Get(tc.id) + require.True(t, ok, "entry %s must survive the reload", tc.id) + assert.Equal(t, tc.state, entry.State, "state of %s", tc.id) + assert.Equal(t, tc.reason, entry.Reason, "reason of %s", tc.id) + } + + // The settled states are written back, so a third start sees them as final + // rather than settling them again. + third := LoadHistory(store) + entry, ok := third.Get("moving") + require.True(t, ok) + assert.Equal(t, StateFailed, entry.State) +} + +func TestSpoolWriteTruncatesStaleTail(t *testing.T) { + spool, err := NewSpool(t.TempDir()) + require.NoError(t, err) + + id := OfferID("offer") + require.NoError(t, spool.Prepare(id)) + + _, err = spool.Write(id, 0, 0, strings.NewReader("AAAAAAAAAA"), 10) + require.NoError(t, err) + + total, err := spool.Write(id, 0, 2, strings.NewReader("BB"), 10) + require.NoError(t, err) + assert.Equal(t, int64(4), total, "staged size follows the resumed write") + + staged, err := os.ReadFile(spool.Path(id, 0)) + require.NoError(t, err) + assert.Equal(t, "AABB", string(staged), "stale bytes past the offset must be dropped") +} + +func TestSpoolCleanupDropsStalePartials(t *testing.T) { + spool, err := NewSpool(t.TempDir()) + require.NoError(t, err) + + stale, fresh := OfferID("stale"), OfferID("fresh") + require.NoError(t, spool.Prepare(stale)) + require.NoError(t, spool.Prepare(fresh)) + + old := time.Now().Add(-2 * time.Hour) + require.NoError(t, os.Chtimes(spool.OfferDir(stale), old, old)) + + spool.Cleanup(time.Hour, time.Now()) + + _, err = os.Stat(spool.OfferDir(stale)) + assert.True(t, os.IsNotExist(err), "the stale offer dir must be removed") + + _, err = os.Stat(spool.OfferDir(fresh)) + assert.NoError(t, err, "a recent offer dir must survive") +} + +func TestParseOfferPath(t *testing.T) { + tests := []struct { + path string + id OfferID + index int + hasIndex bool + wantErr bool + }{ + {path: "/v1/offers/abc", id: "abc"}, + {path: "/v1/offers/abc/files/3", id: "abc", index: 3, hasIndex: true}, + {path: "/v1/offers/", wantErr: true}, + {path: "/v1/offers/abc/files", wantErr: true}, + {path: "/v1/offers/abc/other/1", wantErr: true}, + {path: "/v1/offers/abc/files/-1", wantErr: true}, + {path: "/v1/offers/abc/files/x", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.path, func(t *testing.T) { + id, index, hasIndex, err := parseOfferPath(tc.path) + if tc.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.id, id) + assert.Equal(t, tc.index, index) + assert.Equal(t, tc.hasIndex, hasIndex) + }) + } +} + +func TestValidateOffer(t *testing.T) { + assert.Error(t, validateOffer(nil), "an empty offer is invalid") + assert.Error(t, validateOffer([]FileMeta{{Name: "x", Size: -1}}), "a negative size is invalid") + assert.Error(t, validateOffer(make([]FileMeta, MaxOfferFiles+1)), "too many files is invalid") + assert.Error(t, validateOffer([]FileMeta{{ + Name: "x", Kind: KindText, Text: strings.Repeat("a", MaxInlineTextSize+1), + }}), "oversized inline text is invalid") + + assert.NoError(t, validateOffer([]FileMeta{{Name: "x", Size: 10}})) +} + +func TestStopIsIdempotent(t *testing.T) { + srv, err := NewServer(ServerConfig{ + SpoolDir: t.TempDir(), + Policy: NewPolicyStore(testProfile), + Resolver: staticResolver{key: testPeer}, + }) + require.NoError(t, err) + + require.NoError(t, srv.Stop(), "stopping a server that never started is a no-op") + + require.NoError(t, srv.Start(context.Background(), netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0))) + require.NoError(t, srv.Stop()) + require.NoError(t, srv.Stop(), "the second stop must also be a no-op") +} + +func TestStartRejectsSecondStart(t *testing.T) { + srv, err := NewServer(ServerConfig{ + SpoolDir: t.TempDir(), + Policy: NewPolicyStore(testProfile), + Resolver: staticResolver{key: testPeer}, + }) + require.NoError(t, err) + + addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0) + require.NoError(t, srv.Start(context.Background(), addr)) + t.Cleanup(func() { + require.NoError(t, srv.Stop()) + }) + + err = srv.Start(context.Background(), addr) + require.Error(t, err, "a running server must reject a second start") + + srv.mu.RLock() + running := srv.httpServer != nil && srv.listener != nil + srv.mu.RUnlock() + assert.True(t, running, "the original listener must survive the rejected start") +} + +func TestNewServerRequiresResolver(t *testing.T) { + _, err := NewServer(ServerConfig{SpoolDir: t.TempDir(), Policy: NewPolicyStore(testProfile)}) + require.Error(t, err, "a server without peer resolution must not be constructed") +} + +func TestNewServerRequiresPolicy(t *testing.T) { + _, err := NewServer(ServerConfig{SpoolDir: t.TempDir(), Resolver: staticResolver{key: testPeer}}) + require.Error(t, err, "a server without a profile policy must not be constructed") +} + +func TestNewClientRequiresDialer(t *testing.T) { + _, err := NewClient(ClientConfig{}) + require.Error(t, err, "a client without a dialer must not be constructed") +} + +func TestSendRejectsEmptyPayloadSet(t *testing.T) { + _, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer}) + + _, err := client.Send(context.Background(), testAddr, nil, nil) + assert.True(t, errors.Is(err, ErrInvalidOffer), "sending nothing is an invalid offer") +} + +func TestServerFallsBackWhenPortBusy(t *testing.T) { + blocker, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "blocker listener must bind") + defer func() { + require.NoError(t, blocker.Close()) + }() + busyPort := uint16(blocker.Addr().(*net.TCPAddr).Port) + + srv, err := NewServer(ServerConfig{ + SpoolDir: t.TempDir(), + Policy: NewPolicyStore(testProfile), + Resolver: staticResolver{key: testPeer}, + }) + require.NoError(t, err) + + addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), busyPort) + require.NoError(t, srv.Start(context.Background(), addr), "start must fall back instead of failing") + t.Cleanup(func() { + require.NoError(t, srv.Stop()) + }) + + bound := srv.BoundPort() + assert.NotZero(t, bound, "fallback must report the bound port") + assert.NotEqual(t, busyPort, bound, "fallback must pick a different port") +} + +func TestPortRegistryAwait(t *testing.T) { + reg := NewPortRegistry() + + reg.Set(testPeer, 5000) + port, changed := reg.Await(context.Background(), testPeer, 0) + assert.True(t, changed, "known differing port must return immediately") + assert.Equal(t, uint16(5000), port) + + go func() { + time.Sleep(50 * time.Millisecond) + reg.Set(testPeer, 5000) + }() + _, changed = reg.Await(context.Background(), testPeer, 5000) + assert.False(t, changed, "an advertisement equal to the used port must release the waiter as unchanged") + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, changed = reg.Await(ctx, testPeer, 5000) + assert.False(t, changed, "timeout without advertisement must report unchanged") +} + +// senderManager builds a send-only manager whose dialer reaches the test server only +// on realPort; other ports behave per defaultPortBehavior ("refuse" or "hang"). +func senderManager(t *testing.T, serverAddr string, realPort uint16, defaultPortBehavior string) *Manager { + t.Helper() + + mgr, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir()}) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, mgr.Close()) + }) + + mgr.SetTunnel(func(ctx context.Context, network, addr string) (net.Conn, error) { + ap, err := netip.ParseAddrPort(addr) + require.NoError(t, err, "dialer must receive a valid addr") + if ap.Port() == realPort { + var d net.Dialer + return d.DialContext(ctx, network, serverAddr) + } + if defaultPortBehavior == "hang" { + <-ctx.Done() + return nil, ctx.Err() + } + return nil, &net.OpError{Op: "dial", Net: network, Err: errors.New("connection refused")} + }, "sender") + + return mgr +} + +func waitForState(t *testing.T, mgr *Manager, id OfferID, want State) { + t.Helper() + require.Eventually(t, func() bool { + tr, ok := mgr.history.Get(id) + return ok && tr.State == want + }, 10*time.Second, 20*time.Millisecond, "transfer must reach state %s", want) +} + +func TestSendRetriesOnAdvertisedPort(t *testing.T) { + srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: PeerKey("sender-key")}) + srv.mu.RLock() + serverAddr := srv.listener.Addr().String() + srv.mu.RUnlock() + realPort := srv.BoundPort() + + mgr := senderManager(t, serverAddr, realPort, "refuse") + + id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")}) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + mgr.Ports().Set(testPeer, realPort) + + waitForState(t, mgr, id, StateCompleted) +} + +func TestSendAbortsHangingAttemptOnAdvertisedPort(t *testing.T) { + srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: PeerKey("sender-key")}) + srv.mu.RLock() + serverAddr := srv.listener.Addr().String() + srv.mu.RUnlock() + realPort := srv.BoundPort() + + mgr := senderManager(t, serverAddr, realPort, "hang") + + id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")}) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + mgr.Ports().Set(testPeer, realPort) + + waitForState(t, mgr, id, StateCompleted) +} + +func TestSendFailsWhenSignalConfirmsUsedPort(t *testing.T) { + mgr := senderManager(t, "127.0.0.1:1", 1, "refuse") + + id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")}) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + mgr.Ports().Set(testPeer, 0) + + waitForState(t, mgr, id, StateFailed) +} diff --git a/client/internal/filedrop/history.go b/client/internal/filedrop/history.go new file mode 100644 index 000000000..7a0c24ce3 --- /dev/null +++ b/client/internal/filedrop/history.go @@ -0,0 +1,205 @@ +package filedrop + +import ( + "fmt" + "slices" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +// The transfer directions. +const ( + DirectionReceived Direction = iota + DirectionSent +) + +// The failure reasons a transfer can end with; None accompanies every other state. +const ( + ReasonNone FailureReason = iota + // ReasonUnreachable marks a transport-level failure: nothing listens on the + // peer's file drop port, so the client is old or receiving is off. + ReasonUnreachable + // ReasonInterrupted marks a transfer that was still moving when the process + // stopped; nothing survived to finish or resume it. + ReasonInterrupted +) + +const historyCap = 30 + +// Direction tells whether a transfer was sent by this device or received on it. +type Direction uint8 + +// FailureReason classifies why a transfer failed, when it is known. +type FailureReason uint8 + +// String implements fmt.Stringer. +func (d Direction) String() string { + switch d { + case DirectionReceived: + return "received" + case DirectionSent: + return "sent" + default: + return fmt.Sprintf("unknown(%d)", uint8(d)) + } +} + +// Transfer is one history entry: a sent or received offer with its outcome. +type Transfer struct { + ID OfferID `json:"id"` + Direction Direction `json:"direction"` + PeerKey PeerKey `json:"peerKey"` + PeerName string `json:"peerName"` + Files []FileMeta `json:"files"` + State State `json:"state"` + Transferred int64 `json:"transferred"` + TotalSize int64 `json:"totalSize"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeliveredPaths []string `json:"deliveredPaths,omitempty"` + Error string `json:"error,omitempty"` + Reason FailureReason `json:"reason,omitempty"` +} + +// History is the persisted transfer log of one profile, newest first. +type History struct { + mu sync.RWMutex + store Store + entries []Transfer +} + +// LoadHistory reads the stored transfer log, starting empty when unreadable. +func LoadHistory(store Store) *History { + h := &History{store: store} + + var entries []Transfer + if err := loadSection(store, namespaceHistory, &entries); err != nil { + log.Warnf("failed to read file drop history, starting empty: %v", err) + return h + } + h.entries = entries + if h.settleInterrupted() { + h.persist() + } + return h +} + +func (t Transfer) terminal() bool { + switch t.State { + case StateCompleted, StateDeclined, StateExpired, StateCancelled, StateFailed: + return true + default: + return false + } +} + +func (t Transfer) clone() Transfer { + c := t + c.Files = slices.Clone(t.Files) + c.DeliveredPaths = slices.Clone(t.DeliveredPaths) + return c +} + +// Upsert inserts or replaces the entry with the same ID and persists the log. +func (h *History) Upsert(t Transfer) { + t.UpdatedAt = time.Now() + + h.mu.Lock() + defer h.mu.Unlock() + + if i := h.indexOf(t.ID); i >= 0 { + h.entries[i] = t + } else { + h.entries = slices.Insert(h.entries, 0, t) + h.prune() + } + h.persist() +} + +// SetProgress updates the transferred byte count in memory only. +func (h *History) SetProgress(id OfferID, transferred int64) { + h.mu.Lock() + defer h.mu.Unlock() + + if i := h.indexOf(id); i >= 0 { + h.entries[i].Transferred = transferred + h.entries[i].UpdatedAt = time.Now() + if h.entries[i].State == StatePending { + h.entries[i].State = StateTransferring + } + } +} + +// Get returns the entry with the given ID. +func (h *History) Get(id OfferID) (Transfer, bool) { + h.mu.RLock() + defer h.mu.RUnlock() + + if i := h.indexOf(id); i >= 0 { + return h.entries[i].clone(), true + } + return Transfer{}, false +} + +// List returns every entry, newest first. +func (h *History) List() []Transfer { + h.mu.RLock() + defer h.mu.RUnlock() + + list := make([]Transfer, len(h.entries)) + for i, e := range h.entries { + list[i] = e.clone() + } + return list +} + +// Delete removes one entry and persists the log. +func (h *History) Delete(id OfferID) { + h.mu.Lock() + defer h.mu.Unlock() + + if i := h.indexOf(id); i >= 0 { + h.entries = slices.Delete(h.entries, i, i+1) + h.persist() + } +} + +func (h *History) indexOf(id OfferID) int { + return slices.IndexFunc(h.entries, func(t Transfer) bool { return t.ID == id }) +} + +func (h *History) prune() { + if len(h.entries) <= historyCap { + return + } + for i := len(h.entries) - 1; i >= 0 && len(h.entries) > historyCap; i-- { + if h.entries[i].terminal() { + h.entries = slices.Delete(h.entries, i, i+1) + } + } +} + +// settleInterrupted closes out transfers that were still moving when the +// process died. Nothing is left to finish them, so left alone they would sit in +// the log as permanently pending. Reports whether anything changed. +func (h *History) settleInterrupted() bool { + changed := false + for i, t := range h.entries { + if t.terminal() { + continue + } + h.entries[i].State = StateFailed + h.entries[i].Reason = ReasonInterrupted + h.entries[i].UpdatedAt = time.Now() + changed = true + } + return changed +} + +func (h *History) persist() { + if err := saveSection(h.store, namespaceHistory, h.entries); err != nil { + log.Warnf("failed to write file drop history: %v", err) + } +} diff --git a/client/internal/filedrop/http.go b/client/internal/filedrop/http.go new file mode 100644 index 000000000..93e16d582 --- /dev/null +++ b/client/internal/filedrop/http.go @@ -0,0 +1,221 @@ +package filedrop + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" +) + +// httpTransport adapts the receiver to plain HTTP/1.1 over the tunnel. It only +// parses requests, maps domain errors to status codes, and encodes responses. +type httpTransport struct { + recv *receiver +} + +func (t *httpTransport) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc(pathOffers, t.handleOffers) + mux.HandleFunc(pathOffersSlash, t.handleOffer) + return mux +} + +func (t *httpTransport) handleOffers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + sender, ok := t.identify(w, r) + if !ok { + return + } + + var req OfferRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxOfferBodySize)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed offer") + return + } + + offer, err := t.recv.submitOffer(sender, req) + if err != nil { + writeDomainError(w, err) + return + } + + status := http.StatusAccepted + if offer.Decision == DecisionAccepted { + status = http.StatusCreated + } + writeJSON(w, status, OfferResponse{ID: offer.ID, Decision: offer.Decision}) +} + +func (t *httpTransport) handleOffer(w http.ResponseWriter, r *http.Request) { + sender, ok := t.identify(w, r) + if !ok { + return + } + + id, index, hasIndex, err := parseOfferPath(r.URL.Path) + if err != nil { + writeError(w, http.StatusNotFound, "unknown path") + return + } + + if !hasIndex { + switch r.Method { + case http.MethodGet: + t.handleOfferStatus(w, r, sender, id) + case http.MethodDelete: + t.handleOfferCancel(w, sender, id) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } + return + } + + switch r.Method { + case http.MethodPut: + t.handleUpload(w, r, sender, id, index) + case http.MethodHead: + t.handleUploadProbe(w, sender, id, index) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (t *httpTransport) handleOfferStatus(w http.ResponseWriter, r *http.Request, sender senderIdentity, id OfferID) { + offer, err := t.recv.awaitDecision(r.Context(), sender, id) + if err != nil { + if errors.Is(err, ErrOfferNotFound) { + writeDomainError(w, err) + } + return + } + writeJSON(w, http.StatusOK, OfferResponse{ID: offer.ID, Decision: offer.Decision}) +} + +func (t *httpTransport) handleOfferCancel(w http.ResponseWriter, sender senderIdentity, id OfferID) { + if err := t.recv.withdraw(sender, id); err != nil { + writeDomainError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (t *httpTransport) handleUploadProbe(w http.ResponseWriter, sender senderIdentity, id OfferID, index int) { + received, err := t.recv.receivedBytes(sender, id, index) + if err != nil { + writeDomainError(w, err) + return + } + w.Header().Set(HeaderReceivedBytes, strconv.FormatInt(received, 10)) + w.WriteHeader(http.StatusOK) +} + +func (t *httpTransport) handleUpload(w http.ResponseWriter, r *http.Request, sender senderIdentity, id OfferID, index int) { + offset, err := parseOffset(r.URL.Query().Get("offset")) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := t.recv.upload(sender, id, index, offset, r.Body); err != nil { + writeDomainError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (t *httpTransport) identify(w http.ResponseWriter, r *http.Request) (senderIdentity, bool) { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + + addr, err := netip.ParseAddr(host) + if err != nil { + writeError(w, http.StatusForbidden, "unknown sender") + return senderIdentity{}, false + } + + sender, ok := t.recv.identify(addr) + if !ok { + writeError(w, http.StatusForbidden, "unknown sender") + return senderIdentity{}, false + } + return sender, true +} + +func writeDomainError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, ErrRefused), errors.Is(err, ErrNotAccepted), errors.Is(err, ErrUnknownPeer): + writeError(w, http.StatusForbidden, err.Error()) + case errors.Is(err, ErrOfferNotFound): + writeError(w, http.StatusNotFound, err.Error()) + case errors.Is(err, ErrInvalidOffer): + writeError(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, ErrStorage): + writeError(w, http.StatusInsufficientStorage, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } +} + +func parseOfferPath(path string) (OfferID, int, bool, error) { + rest := strings.TrimPrefix(path, pathOffersSlash) + if rest == "" || rest == path { + return "", 0, false, fmt.Errorf("not an offer path") + } + + parts := strings.Split(rest, "/") + if parts[0] == "" { + return "", 0, false, fmt.Errorf("missing offer id") + } + id := OfferID(parts[0]) + + switch len(parts) { + case 1: + return id, 0, false, nil + case 3: + if parts[1] != segmentFiles { + return "", 0, false, fmt.Errorf("unknown sub-resource %q", parts[1]) + } + index, err := strconv.Atoi(parts[2]) + if err != nil || index < 0 { + return "", 0, false, fmt.Errorf("invalid file index") + } + return id, index, true, nil + default: + return "", 0, false, fmt.Errorf("unknown offer path") + } +} + +func parseOffset(raw string) (int64, error) { + if raw == "" { + return 0, nil + } + offset, err := strconv.ParseInt(raw, 10, 64) + if err != nil || offset < 0 { + return 0, fmt.Errorf("invalid offset") + } + return offset, nil +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(body); err != nil { + log.Debugf("write file drop response: %v", err) + } +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/client/internal/filedrop/manager.go b/client/internal/filedrop/manager.go new file mode 100644 index 000000000..b3ec25cd8 --- /dev/null +++ b/client/internal/filedrop/manager.go @@ -0,0 +1,660 @@ +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 not an event: live transfers are polled. +const ( + EventOffer EventKind = iota + EventCompleted + EventFailed + EventWithdrawn +) + +// portSignalGrace bounds how long a failed attempt waits for one signal message +// that may advertise the receiver's actual port before giving up. +const portSignalGrace = 3 * time.Second + +// 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 +} + +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 + + server *Server + ports *PortRegistry + 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, + ports: NewPortRegistry(), + 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 +} + +// Ports returns the registry of peer-advertised listen ports; the engine feeds it +// from incoming signal messages. +func (m *Manager) Ports() *PortRegistry { + return m.ports +} + +// 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 the directory received files are delivered to. +func (m *Manager) DestinationDir() string { + 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"), + 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().Decide(id, DecisionDeclined); 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, remoteID, decision, err := m.offerWithPortRetry(ctx, client, handle, transfer.PeerKey, payloads) + if err != nil { + m.failSend(ctx, transfer.ID, err) + return + } + + 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) + } + + 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)) +} + +// offerWithPortRetry places the offer on the last advertised port, falling back to +// the default. When the attempt fails on the transport, it waits out one signal +// message that may carry the receiver's actual port and retries there once. A port +// learned mid-attempt aborts the attempt immediately instead of letting it hang. +func (m *Manager) offerWithPortRetry(ctx context.Context, client *Client, handle *sendHandle, key PeerKey, payloads []Payload) (netip.AddrPort, OfferID, Decision, error) { + used := m.ports.Port(key) + addr := netip.AddrPortFrom(handle.ip, effectivePort(used)) + + remoteID, decision, err := m.offerWatchingPorts(ctx, client, key, used, addr, payloads) + if err == nil { + m.storeRemote(handle, addr, remoteID) + return addr, remoteID, decision, nil + } + if ctx.Err() != nil || !transportFailure(err) { + return addr, remoteID, decision, err + } + + graceCtx, cancel := context.WithTimeout(ctx, portSignalGrace) + port, changed := m.ports.Await(graceCtx, key, used) + cancel() + if !changed { + return addr, remoteID, decision, err + } + + addr = netip.AddrPortFrom(handle.ip, effectivePort(port)) + remoteID, decision, err = client.Offer(ctx, addr, payloads) + if err != nil { + return addr, remoteID, decision, err + } + m.storeRemote(handle, addr, remoteID) + return addr, remoteID, decision, nil +} + +// offerWatchingPorts runs the offer while watching for a port advertisement that +// differs from the one in use; such an advertisement aborts the in-flight attempt. +func (m *Manager) offerWatchingPorts(ctx context.Context, client *Client, key PeerKey, used uint16, addr netip.AddrPort, payloads []Payload) (OfferID, Decision, error) { + watchCtx, cancel := context.WithCancel(ctx) + defer cancel() + + go func() { + if _, changed := m.ports.Await(watchCtx, key, used); changed { + cancel() + } + }() + + return client.Offer(watchCtx, addr, payloads) +} + +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 { + case errors.Is(err, ErrDeclined): + 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) +} + +// 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 := deliver(server.Spool(), offer, m.policy.DestinationDir()) + if err != nil { + log.Errorf("failed to deliver file drop payloads: %v", err) + 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 +} + +func effectivePort(advertised uint16) uint16 { + if advertised == 0 { + return Port + } + return advertised +} + +// transportFailure reports whether the offer never reached the receiver; any HTTP +// response, refusal included, proves the port right and is not retried elsewhere. +func transportFailure(err error) bool { + var urlErr *url.Error + return errors.As(err, &urlErr) +} diff --git a/client/internal/filedrop/offer.go b/client/internal/filedrop/offer.go new file mode 100644 index 000000000..0f174f15e --- /dev/null +++ b/client/internal/filedrop/offer.go @@ -0,0 +1,282 @@ +package filedrop + +import ( + "context" + "sync" + "time" + + "github.com/google/uuid" +) + +// Offer is one incoming transfer as tracked by the receiver. +type Offer struct { + ID OfferID + Sender PeerKey + SenderName string + Files []FileMeta + Decision Decision + State State + CreatedAt time.Time + ExpiresAt time.Time + Progress []int64 +} + +type offerEntry struct { + offer Offer + decided chan struct{} +} + +// OfferStore tracks incoming offers and their decisions. +type OfferStore struct { + mu sync.RWMutex + offers map[OfferID]*offerEntry + ttl time.Duration + newID func() OfferID + nowFunc func() time.Time +} + +// NewOfferStore returns an empty store using ttl as the decision deadline. +func NewOfferStore(ttl time.Duration) *OfferStore { + if ttl <= 0 { + ttl = DefaultOfferTTL + } + return &OfferStore{ + offers: make(map[OfferID]*offerEntry), + ttl: ttl, + newID: func() OfferID { return OfferID(uuid.NewString()) }, + nowFunc: time.Now, + } +} + +// Add registers a new offer with the given initial decision and returns its snapshot. +func (s *OfferStore) Add(sender PeerKey, senderName string, files []FileMeta, decision Decision) Offer { + now := s.nowFunc() + + entry := &offerEntry{ + offer: Offer{ + ID: s.newID(), + Sender: sender, + SenderName: senderName, + Files: files, + Decision: decision, + State: stateForDecision(decision), + CreatedAt: now, + ExpiresAt: now.Add(s.ttl), + Progress: make([]int64, len(files)), + }, + decided: make(chan struct{}), + } + if decision != DecisionPending { + close(entry.decided) + } + + s.mu.Lock() + s.offers[entry.offer.ID] = entry + s.mu.Unlock() + + return entry.offer.clone() +} + +// Get returns a snapshot of one offer belonging to sender. +func (s *OfferStore) Get(sender PeerKey, id OfferID) (Offer, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + entry, ok := s.offers[id] + if !ok || entry.offer.Sender != sender { + return Offer{}, false + } + return entry.offer.clone(), true +} + +// List returns snapshots of every tracked offer. +func (s *OfferStore) List() []Offer { + s.mu.RLock() + defer s.mu.RUnlock() + + offers := make([]Offer, 0, len(s.offers)) + for _, entry := range s.offers { + offers = append(offers, entry.offer.clone()) + } + return offers +} + +// Decide records the receiver's answer; a made decision is final. +func (s *OfferStore) Decide(id OfferID, decision Decision) (Offer, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + entry, ok := s.offers[id] + if !ok || entry.offer.Decision != DecisionPending { + return Offer{}, false + } + + entry.offer.Decision = decision + entry.offer.State = stateForDecision(decision) + if decision == DecisionAccepted && entry.offer.awaitsNoUpload() { + entry.offer.State = StateCompleted + } + close(entry.decided) + + return entry.offer.clone(), true +} + +// Await blocks until a decision, expiry, or ctx cancellation. +func (s *OfferStore) Await(ctx context.Context, sender PeerKey, id OfferID) (Offer, error) { + s.mu.RLock() + entry, ok := s.offers[id] + if ok && entry.offer.Sender != sender { + ok = false + } + var decided chan struct{} + var expiresAt time.Time + if ok { + decided = entry.decided + expiresAt = entry.offer.ExpiresAt + } + s.mu.RUnlock() + + if !ok { + return Offer{}, ErrOfferNotFound + } + + timer := time.NewTimer(time.Until(expiresAt)) + defer timer.Stop() + + select { + case <-decided: + case <-timer.C: + s.Decide(id, DecisionExpired) + case <-ctx.Done(): + offer, _ := s.Get(sender, id) + return offer, ctx.Err() + } + + offer, ok := s.Get(sender, id) + if !ok { + return Offer{}, ErrOfferNotFound + } + return offer, nil +} + +// SetProgress records the staged byte count for one file of an offer. +func (s *OfferStore) SetProgress(id OfferID, index int, received int64) { + s.mu.Lock() + defer s.mu.Unlock() + + entry, ok := s.offers[id] + if !ok || index < 0 || index >= len(entry.offer.Progress) { + return + } + entry.offer.Progress[index] = received + if entry.offer.State == StatePending { + entry.offer.State = StateTransferring + } +} + +// SetState overrides the transfer state, for completion and failure reporting. +func (s *OfferStore) SetState(id OfferID, state State) { + s.mu.Lock() + defer s.mu.Unlock() + + if entry, ok := s.offers[id]; ok { + entry.offer.State = state + } +} + +// Remove drops an offer from the store. +func (s *OfferStore) Remove(id OfferID) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.offers, id) +} + +// ExpireOverdue marks every pending offer past its deadline as expired and returns them. +func (s *OfferStore) ExpireOverdue() []Offer { + now := s.nowFunc() + + s.mu.Lock() + defer s.mu.Unlock() + + var expired []Offer + for _, entry := range s.offers { + if entry.offer.Decision != DecisionPending || now.Before(entry.offer.ExpiresAt) { + continue + } + entry.offer.Decision = DecisionExpired + entry.offer.State = StateExpired + close(entry.decided) + expired = append(expired, entry.offer.clone()) + } + return expired +} + +// Complete marks an offer completed once every file reached its announced size. +func (s *OfferStore) Complete(id OfferID) (Offer, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + entry, ok := s.offers[id] + if !ok || entry.offer.State == StateCompleted { + return Offer{}, false + } + + if !entry.offer.fullyStaged() { + return Offer{}, false + } + + entry.offer.State = StateCompleted + return entry.offer.clone(), true +} + +func (o Offer) awaitsNoUpload() bool { + for _, f := range o.Files { + if f.Kind != KindText { + return false + } + } + return true +} + +func (o Offer) fullyStaged() bool { + for i, f := range o.Files { + if f.Kind == KindText { + continue + } + if i >= len(o.Progress) || o.Progress[i] < f.Size { + return false + } + } + return true +} + +func (o Offer) clone() Offer { + c := o + c.Files = make([]FileMeta, len(o.Files)) + copy(c.Files, o.Files) + c.Progress = make([]int64, len(o.Progress)) + copy(c.Progress, o.Progress) + return c +} + +// TotalSize is the announced byte count across every file of the offer. +func (o Offer) TotalSize() int64 { + var total int64 + for _, f := range o.Files { + total += f.Size + } + return total +} + +func stateForDecision(d Decision) State { + switch d { + case DecisionAccepted: + return StateTransferring + case DecisionDeclined: + return StateDeclined + case DecisionExpired: + return StateExpired + default: + return StatePending + } +} diff --git a/client/internal/filedrop/policy.go b/client/internal/filedrop/policy.go new file mode 100644 index 000000000..dd0920dc0 --- /dev/null +++ b/client/internal/filedrop/policy.go @@ -0,0 +1,206 @@ +package filedrop + +import ( + "fmt" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +const ( + SenderRuleDefault SenderRule = iota + SenderRuleAlwaysAccept + SenderRuleBlock +) + +// SenderRule is a per-sender override on top of the base mode. +type SenderRule uint8 + +// Policy is the device-local receiving policy of one profile. An empty +// DestinationDir means the platform's default download directory. +type Policy struct { + Mode Mode `json:"mode"` + Senders map[PeerKey]SenderRule `json:"senders,omitempty"` + DestinationDir string `json:"destinationDir,omitempty"` +} + +// PolicyStore holds the receiving policy of one profile and evaluates it per sender. +type PolicyStore struct { + mu sync.RWMutex + profile profilemanager.ID + policy Policy + store Store +} + +// NewPolicyStore returns an in-memory store seeded with the default policy. +func NewPolicyStore(profile profilemanager.ID) *PolicyStore { + return &PolicyStore{profile: profile, policy: DefaultPolicy()} +} + +// LoadPolicyStore builds a store from the persisted policy of one profile. +func LoadPolicyStore(profile profilemanager.ID, store Store) *PolicyStore { + s := &PolicyStore{ + profile: profile, + policy: DefaultPolicy(), + store: store, + } + if store == nil { + return s + } + + policy := DefaultPolicy() + if err := loadSection(store, namespacePolicy, &policy); err != nil { + log.Warnf("failed to load file drop policy for profile %s, using defaults: %v", profile, err) + return s + } + if err := policy.validate(); err != nil { + log.Warnf("stored file drop policy for profile %s is invalid, using defaults: %v", profile, err) + return s + } + + s.policy = policy.normalized() + return s +} + +// String implements fmt.Stringer. +func (r SenderRule) String() string { + switch r { + case SenderRuleDefault: + return "default" + case SenderRuleAlwaysAccept: + return "always" + case SenderRuleBlock: + return "block" + default: + return fmt.Sprintf("unknown(%d)", uint8(r)) + } +} + +func (p Policy) validate() error { + if !p.Mode.valid() { + return fmt.Errorf("invalid mode %s", p.Mode) + } + return nil +} + +func (p Policy) normalized() Policy { + c := p.clone() + if c.Senders == nil { + c.Senders = map[PeerKey]SenderRule{} + } + return c +} + +func (p Policy) clone() Policy { + c := p + c.Senders = make(map[PeerKey]SenderRule, len(p.Senders)) + for k, v := range p.Senders { + c.Senders[k] = v + } + return c +} + +// Profile returns the profile this policy belongs to. +func (s *PolicyStore) Profile() profilemanager.ID { + return s.profile +} + +// Get returns a copy of the current policy. +func (s *PolicyStore) Get() Policy { + s.mu.RLock() + defer s.mu.RUnlock() + return s.policy.clone() +} + +// Set replaces the policy and persists it. +func (s *PolicyStore) Set(p Policy) error { + if err := p.validate(); err != nil { + return err + } + + s.mu.Lock() + s.policy = p.normalized() + store, stored := s.store, s.policy.clone() + s.mu.Unlock() + + return saveSection(store, namespacePolicy, stored) +} + +// SetMode changes the base mode, leaving per-sender rules untouched. +func (s *PolicyStore) SetMode(m Mode) error { + if !m.valid() { + return fmt.Errorf("invalid mode %s", m) + } + + s.mu.Lock() + s.policy.Mode = m + store, stored := s.store, s.policy.clone() + s.mu.Unlock() + + return saveSection(store, namespacePolicy, stored) +} + +// SetSenderRule sets or clears the override for a single sender. +func (s *PolicyStore) SetSenderRule(key PeerKey, rule SenderRule) error { + s.mu.Lock() + if rule == SenderRuleDefault { + delete(s.policy.Senders, key) + } else { + if s.policy.Senders == nil { + s.policy.Senders = map[PeerKey]SenderRule{} + } + s.policy.Senders[key] = rule + } + store, stored := s.store, s.policy.clone() + s.mu.Unlock() + + return saveSection(store, namespacePolicy, stored) +} + +// DestinationDir returns the directory received files are delivered to. +func (s *PolicyStore) DestinationDir() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.policy.DestinationDir +} + +// SetDestinationDir persists the delivery directory. +func (s *PolicyStore) SetDestinationDir(dir string) error { + s.mu.Lock() + s.policy.DestinationDir = dir + store, stored := s.store, s.policy.clone() + s.mu.Unlock() + + return saveSection(store, namespacePolicy, stored) +} + +// Evaluate returns the mode that applies to one sender, denying on unknown values. +func (s *PolicyStore) Evaluate(key PeerKey) Mode { + s.mu.RLock() + defer s.mu.RUnlock() + + switch s.policy.Senders[key] { + case SenderRuleBlock: + return ModeOff + case SenderRuleAlwaysAccept: + return ModeAutoAccept + case SenderRuleDefault: + default: + return ModeOff + } + + if !s.policy.Mode.valid() { + return ModeOff + } + return s.policy.Mode +} + +// DefaultPolicy asks before accepting anything, so receiving is never silently on. +func DefaultPolicy() Policy { + return Policy{ + Mode: ModeAsk, + Senders: map[PeerKey]SenderRule{}, + } +} diff --git a/client/internal/filedrop/ports.go b/client/internal/filedrop/ports.go new file mode 100644 index 000000000..912333fe7 --- /dev/null +++ b/client/internal/filedrop/ports.go @@ -0,0 +1,81 @@ +package filedrop + +import ( + "context" + "sync" +) + +// PortRegistry tracks the file drop listen port each peer advertised over +// signaling; 0 means the well-known default. Senders can wait on it to learn a +// better port after a failed attempt. +type PortRegistry struct { + mu sync.Mutex + ports map[PeerKey]uint16 + waits map[PeerKey][]chan uint16 +} + +// NewPortRegistry returns an empty registry. +func NewPortRegistry() *PortRegistry { + return &PortRegistry{ + ports: make(map[PeerKey]uint16), + waits: make(map[PeerKey][]chan uint16), + } +} + +// Set records the port a peer advertised and releases every waiter for it. +func (r *PortRegistry) Set(key PeerKey, port uint16) { + r.mu.Lock() + r.ports[key] = port + waiters := r.waits[key] + delete(r.waits, key) + r.mu.Unlock() + + for _, ch := range waiters { + ch <- port + } +} + +// Port returns the last advertised port for a peer; 0 means default or unknown. +func (r *PortRegistry) Port(key PeerKey) uint16 { + r.mu.Lock() + defer r.mu.Unlock() + return r.ports[key] +} + +// Await returns the peer's port as soon as it differs from used, or after the next +// advertisement even when it does not, reporting whether it differs. It returns +// immediately when the currently known port already differs. +func (r *PortRegistry) Await(ctx context.Context, key PeerKey, used uint16) (uint16, bool) { + r.mu.Lock() + if port, ok := r.ports[key]; ok && port != used { + r.mu.Unlock() + return port, true + } + ch := make(chan uint16, 1) + r.waits[key] = append(r.waits[key], ch) + r.mu.Unlock() + + select { + case port := <-ch: + return port, port != used + case <-ctx.Done(): + r.drop(key, ch) + return 0, false + } +} + +func (r *PortRegistry) drop(key PeerKey, ch chan uint16) { + r.mu.Lock() + defer r.mu.Unlock() + + waiters := r.waits[key] + for i, w := range waiters { + if w == ch { + r.waits[key] = append(waiters[:i], waiters[i+1:]...) + break + } + } + if len(r.waits[key]) == 0 { + delete(r.waits, key) + } +} diff --git a/client/internal/filedrop/protocol.go b/client/internal/filedrop/protocol.go new file mode 100644 index 000000000..bed9c8248 --- /dev/null +++ b/client/internal/filedrop/protocol.go @@ -0,0 +1,197 @@ +package filedrop + +import ( + "errors" + "fmt" + "time" +) + +const Port uint16 = 41421 + +// HeaderReceivedBytes carries the receiver's confirmed byte count in a HEAD response. +const HeaderReceivedBytes = "Netbird-Received-Bytes" + +// DefaultOfferTTL bounds how long an offer waits for the receiver's decision. +const DefaultOfferTTL = 5 * time.Minute + +// MaxOfferFiles bounds the number of items a single offer may announce. +const MaxOfferFiles = 512 + +// MaxInlineTextSize bounds an inline text snippet, which is held in memory. +const MaxInlineTextSize = 64 * 1024 + +const maxOfferBodySize = 1 << 20 + +const ( + pathOffers = "/v1/offers" + pathOffersSlash = pathOffers + "/" + + segmentFiles = "files" +) + +// The decisions an offer can carry. Pending is the only non-final one. +const ( + DecisionPending Decision = iota + DecisionAccepted + DecisionDeclined + DecisionExpired +) + +// The receiving modes a profile can be in. +const ( + ModeOff Mode = iota + ModeAsk + ModeAutoAccept +) + +// The payload kinds an offer can announce. +const ( + KindFile Kind = iota + KindText +) + +// The states a transfer moves through. +const ( + StatePending State = iota + StateTransferring + StateCompleted + StateDeclined + StateExpired + StateCancelled + StateFailed +) + +var ( + ErrOfferNotFound = errors.New("offer not found") + ErrRefused = errors.New("offer refused by receiver") + ErrDeclined = errors.New("offer declined") + ErrExpired = errors.New("offer expired") + ErrNotAccepted = errors.New("offer not accepted") + ErrUnknownPeer = errors.New("unknown peer") + ErrInvalidOffer = errors.New("invalid offer") +) + +// OfferID identifies a single transfer offer on the receiving peer. +type OfferID string + +// PeerKey is the remote peer's public key, used as the identity for per-sender policy. +type PeerKey string + +// Decision is the receiver's answer to an offer. +type Decision uint8 + +// Mode is the receiver's profile-local policy for incoming offers. +type Mode uint8 + +// Kind distinguishes payloads that are written to the spool from inline text snippets. +type Kind uint8 + +// State is the lifecycle state of a transfer, on either side. +type State uint8 + +// FileMeta describes one payload item announced in an offer. +type FileMeta struct { + Name string `json:"name"` + Size int64 `json:"size"` + ContentType string `json:"contentType,omitempty"` + Kind Kind `json:"kind,omitempty"` + Text string `json:"text,omitempty"` +} + +// OfferRequest is the JSON body of POST /v1/offers. It carries metadata only. +type OfferRequest struct { + SenderName string `json:"senderName,omitempty"` + Files []FileMeta `json:"files"` +} + +// OfferResponse is returned for an offer and for every poll of its status. +type OfferResponse struct { + ID OfferID `json:"id"` + Decision Decision `json:"decision"` +} + +// String implements fmt.Stringer. +func (d Decision) String() string { + switch d { + case DecisionPending: + return "pending" + case DecisionAccepted: + return "accepted" + case DecisionDeclined: + return "declined" + case DecisionExpired: + return "expired" + default: + return fmt.Sprintf("unknown(%d)", uint8(d)) + } +} + +func (d Decision) valid() bool { + switch d { + case DecisionPending, DecisionAccepted, DecisionDeclined, DecisionExpired: + return true + default: + return false + } +} + +// String implements fmt.Stringer. +func (m Mode) String() string { + switch m { + case ModeOff: + return "off" + case ModeAsk: + return "ask" + case ModeAutoAccept: + return "auto" + default: + return fmt.Sprintf("unknown(%d)", uint8(m)) + } +} + +func (m Mode) valid() bool { + switch m { + case ModeOff, ModeAsk, ModeAutoAccept: + return true + default: + return false + } +} + +// String implements fmt.Stringer. +func (k Kind) String() string { + switch k { + case KindFile: + return "file" + case KindText: + return "text" + default: + return fmt.Sprintf("unknown(%d)", uint8(k)) + } +} + +func (k Kind) valid() bool { + return k == KindFile || k == KindText +} + +// String implements fmt.Stringer. +func (s State) String() string { + switch s { + case StatePending: + return "pending" + case StateTransferring: + return "transferring" + case StateCompleted: + return "completed" + case StateDeclined: + return "declined" + case StateExpired: + return "expired" + case StateCancelled: + return "cancelled" + case StateFailed: + return "failed" + default: + return fmt.Sprintf("unknown(%d)", uint8(s)) + } +} diff --git a/client/internal/filedrop/receiver.go b/client/internal/filedrop/receiver.go new file mode 100644 index 000000000..d787e4ecf --- /dev/null +++ b/client/internal/filedrop/receiver.go @@ -0,0 +1,226 @@ +package filedrop + +import ( + "context" + "errors" + "fmt" + "io" + "net/netip" + "time" + + log "github.com/sirupsen/logrus" +) + +// ErrStorage indicates the receiver could not stage payload data locally. +var ErrStorage = errors.New("storage failure") + +type senderIdentity struct { + key PeerKey + name string +} + +// receiver implements the transfer protocol independent of any transport. Every +// operation takes the already-authenticated sender identity and returns domain +// errors for the transport to map. +type receiver struct { + policy *PolicyStore + resolver PeerResolver + notifier Notifier + offers *OfferStore + spool *Spool + spoolMaxAge time.Duration +} + +func newReceiver(cfg ServerConfig, spool *Spool, maxAge time.Duration) *receiver { + return &receiver{ + policy: cfg.Policy, + resolver: cfg.Resolver, + notifier: cfg.Notifier, + offers: NewOfferStore(cfg.OfferTTL), + spool: spool, + spoolMaxAge: maxAge, + } +} + +// identify maps a source overlay address to a known peer, refusing unknown ones. +func (r *receiver) identify(addr netip.Addr) (senderIdentity, bool) { + key, name, ok := r.resolver.ResolvePeer(addr.Unmap()) + if !ok { + return senderIdentity{}, false + } + return senderIdentity{key: key, name: name}, true +} + +func (r *receiver) submitOffer(sender senderIdentity, req OfferRequest) (Offer, error) { + if err := validateOffer(req.Files); err != nil { + return Offer{}, fmt.Errorf("%w: %s", ErrInvalidOffer, err) + } + + mode := r.policy.Evaluate(sender.key) + if mode == ModeOff { + return Offer{}, ErrRefused + } + + senderName := sender.name + if senderName == "" { + senderName = req.SenderName + } + + decision := DecisionPending + if mode == ModeAutoAccept { + decision = DecisionAccepted + } + + offer := r.offers.Add(sender.key, senderName, req.Files, decision) + if err := r.spool.Prepare(offer.ID); err != nil { + r.offers.Remove(offer.ID) + log.Errorf("prepare spool for offer: %v", err) + return Offer{}, fmt.Errorf("%w: prepare spool", ErrStorage) + } + + r.notifyOffer(offer) + + if offer.Decision == DecisionAccepted { + if completed, done := r.offers.Complete(offer.ID); done { + r.notifyCompleted(completed) + } + } + return offer, nil +} + +func (r *receiver) awaitDecision(ctx context.Context, sender senderIdentity, id OfferID) (Offer, error) { + return r.offers.Await(ctx, sender.key, id) +} + +func (r *receiver) withdraw(sender senderIdentity, id OfferID) error { + offer, ok := r.offers.Get(sender.key, id) + if !ok { + return ErrOfferNotFound + } + + r.offers.SetState(id, StateCancelled) + r.offers.Remove(id) + r.spool.Remove(id) + + offer.State = StateCancelled + r.notifyWithdrawn(offer) + return nil +} + +func (r *receiver) receivedBytes(sender senderIdentity, id OfferID, index int) (int64, error) { + offer, ok := r.offers.Get(sender.key, id) + if !ok || index >= len(offer.Files) { + return 0, ErrOfferNotFound + } + + received, err := r.spool.Received(id, index) + if err != nil { + log.Debugf("probe spool file: %v", err) + return 0, fmt.Errorf("%w: read staged size", ErrStorage) + } + return received, nil +} + +func (r *receiver) upload(sender senderIdentity, id OfferID, index int, offset int64, body io.Reader) error { + offer, ok := r.offers.Get(sender.key, id) + if !ok || index >= len(offer.Files) { + return ErrOfferNotFound + } + if offer.Decision != DecisionAccepted { + return ErrNotAccepted + } + if offer.Files[index].Kind == KindText { + return fmt.Errorf("%w: text payloads carry no body", ErrInvalidOffer) + } + + size := offer.Files[index].Size + if offset < 0 || offset > size { + return fmt.Errorf("%w: offset out of range", ErrInvalidOffer) + } + + received, err := r.spool.Write(id, index, offset, body, size) + r.offers.SetProgress(id, index, received) + r.notifyProgress(offer, index, received) + + if err != nil { + r.offers.SetState(id, StateFailed) + r.notifyFailed(offer, err) + log.Debugf("stage payload for offer %s file %d: %v", id, index, err) + return fmt.Errorf("%w: stage payload", ErrStorage) + } + + if completed, ok := r.offers.Complete(id); ok { + r.notifyCompleted(completed) + } + return nil +} + +// expireOverdue reclaims offers past their decision deadline and stale spool data. +func (r *receiver) expireOverdue() { + for _, offer := range r.offers.ExpireOverdue() { + r.spool.Remove(offer.ID) + r.notifyFailed(offer, ErrExpired) + } + r.spool.Cleanup(r.spoolMaxAge, time.Now()) +} + +func (r *receiver) close() { + for _, offer := range r.offers.List() { + r.offers.Remove(offer.ID) + } +} + +func (r *receiver) notifyOffer(offer Offer) { + if r.notifier != nil { + r.notifier.OnOffer(offer) + } +} + +func (r *receiver) notifyProgress(offer Offer, index int, received int64) { + if r.notifier != nil { + r.notifier.OnProgress(offer, index, received) + } +} + +func (r *receiver) notifyCompleted(offer Offer) { + if r.notifier != nil { + r.notifier.OnCompleted(offer) + } +} + +func (r *receiver) notifyFailed(offer Offer, err error) { + if r.notifier != nil { + r.notifier.OnFailed(offer, err) + } +} + +func (r *receiver) notifyWithdrawn(offer Offer) { + if r.notifier != nil { + r.notifier.OnWithdrawn(offer) + } +} + +func validateOffer(files []FileMeta) error { + if len(files) == 0 { + return fmt.Errorf("offer announces no files") + } + if len(files) > MaxOfferFiles { + return fmt.Errorf("offer announces more than %d files", MaxOfferFiles) + } + + for _, f := range files { + if !f.Kind.valid() { + return fmt.Errorf("unknown payload kind %s", f.Kind) + } + if f.Kind == KindText { + if len(f.Text) > MaxInlineTextSize { + return fmt.Errorf("inline text exceeds %d bytes", MaxInlineTextSize) + } + continue + } + if f.Size < 0 { + return fmt.Errorf("negative file size") + } + } + return nil +} diff --git a/client/internal/filedrop/server.go b/client/internal/filedrop/server.go new file mode 100644 index 000000000..ec39535ee --- /dev/null +++ b/client/internal/filedrop/server.go @@ -0,0 +1,262 @@ +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 +} diff --git a/client/internal/filedrop/spool.go b/client/internal/filedrop/spool.go new file mode 100644 index 000000000..646d906fd --- /dev/null +++ b/client/internal/filedrop/spool.go @@ -0,0 +1,131 @@ +package filedrop + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "time" + + log "github.com/sirupsen/logrus" +) + +// Spool stages incoming payloads in an app-private directory. +type Spool struct { + root string +} + +// NewSpool prepares the spool directory tree under root. +func NewSpool(root string) (*Spool, error) { + if root == "" { + return nil, fmt.Errorf("empty spool root") + } + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, fmt.Errorf("create spool root: %w", err) + } + return &Spool{root: root}, nil +} + +// Root returns the spool base directory. +func (s *Spool) Root() string { + return s.root +} + +// OfferDir returns the directory holding one offer's payloads. +func (s *Spool) OfferDir(id OfferID) string { + return filepath.Join(s.root, string(id)) +} + +func (s *Spool) filePath(id OfferID, index int) string { + return filepath.Join(s.OfferDir(id), strconv.Itoa(index)) +} + +// Prepare creates the directory for an offer. +func (s *Spool) Prepare(id OfferID) error { + if err := os.MkdirAll(s.OfferDir(id), 0o700); err != nil { + return fmt.Errorf("create offer dir: %w", err) + } + return nil +} + +// Received returns how many bytes of one item are already staged. +func (s *Spool) Received(id OfferID, index int) (int64, error) { + info, err := os.Stat(s.filePath(id, index)) + if os.IsNotExist(err) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("stat spool file: %w", err) + } + return info.Size(), nil +} + +// Write appends the payload at offset, truncating any bytes past it first. +func (s *Spool) Write(id OfferID, index int, offset int64, r io.Reader, limit int64) (int64, error) { + if offset < 0 { + return 0, fmt.Errorf("negative offset %d", offset) + } + + path := s.filePath(id, index) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return 0, fmt.Errorf("open spool file: %w", err) + } + defer func() { + if err := f.Close(); err != nil { + log.Debugf("close spool file: %v", err) + } + }() + + if err := f.Truncate(offset); err != nil { + return 0, fmt.Errorf("truncate spool file: %w", err) + } + if _, err := f.Seek(offset, io.SeekStart); err != nil { + return 0, fmt.Errorf("seek spool file: %w", err) + } + + written, err := io.Copy(f, io.LimitReader(r, limit-offset)) + if err != nil { + return offset + written, fmt.Errorf("write spool file: %w", err) + } + return offset + written, nil +} + +// Path returns the staged path of one item for the platform layer to deliver from. +func (s *Spool) Path(id OfferID, index int) string { + return s.filePath(id, index) +} + +// Remove deletes an offer's staged payloads. +func (s *Spool) Remove(id OfferID) { + if err := os.RemoveAll(s.OfferDir(id)); err != nil { + log.Debugf("remove spool dir: %v", err) + } +} + +// Cleanup removes offer directories older than maxAge. +func (s *Spool) Cleanup(maxAge time.Duration, now time.Time) { + entries, err := os.ReadDir(s.root) + if err != nil { + log.Debugf("read spool root: %v", err) + return + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + log.Debugf("stat spool entry: %v", err) + continue + } + if now.Sub(info.ModTime()) < maxAge { + continue + } + if err := os.RemoveAll(filepath.Join(s.root, entry.Name())); err != nil { + log.Debugf("remove stale spool dir: %v", err) + } + } +} diff --git a/client/internal/filedrop/store.go b/client/internal/filedrop/store.go new file mode 100644 index 000000000..98d17d153 --- /dev/null +++ b/client/internal/filedrop/store.go @@ -0,0 +1,58 @@ +package filedrop + +import ( + "fmt" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +const ( + namespacePolicy = "filedrop" + namespaceHistory = "filedrop-history" +) + +// Store persists one profile's file drop state in namespaced sections. +type Store interface { + Get(namespace string, v any) (bool, error) + Put(namespace string, v any) error +} + +type profileStore struct { + prefs *profilemanager.Prefs +} + +// NewProfileStore returns the store backed by the profile's preferences. +func NewProfileStore(prefs *profilemanager.Prefs) Store { + if prefs == nil { + return nil + } + return &profileStore{prefs: prefs} +} + +func (s *profileStore) Get(namespace string, v any) (bool, error) { + return s.prefs.Get(namespace, v) +} + +func (s *profileStore) Put(namespace string, v any) error { + return s.prefs.Put(namespace, v) +} + +func loadSection(store Store, namespace string, v any) error { + if store == nil { + return nil + } + if _, err := store.Get(namespace, v); err != nil { + return fmt.Errorf("load %s: %w", namespace, err) + } + return nil +} + +func saveSection(store Store, namespace string, v any) error { + if store == nil { + return nil + } + if err := store.Put(namespace, v); err != nil { + return fmt.Errorf("save %s: %w", namespace, err) + } + return nil +} diff --git a/client/internal/peer/signaler.go b/client/internal/peer/signaler.go index 5e437d96b..abc3ec4b3 100644 --- a/client/internal/peer/signaler.go +++ b/client/internal/peer/signaler.go @@ -1,6 +1,8 @@ package peer import ( + "sync/atomic" + "github.com/pion/ice/v4" log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" @@ -12,6 +14,7 @@ import ( type Signaler struct { signal signal.Client wgPrivateKey wgtypes.Key + filedropPort atomic.Uint32 } func NewSignaler(signal signal.Client, wgPrivateKey wgtypes.Key) *Signaler { @@ -44,6 +47,12 @@ func (s *Signaler) Ready() bool { return s.signal.Ready() } +// SetFiledropPort sets the file drop listen port advertised in offers and answers; +// 0 means the well-known default and is not put on the wire. +func (s *Signaler) SetFiledropPort(port uint16) { + s.filedropPort.Store(uint32(port)) +} + // SignalOfferAnswer signals either an offer or an answer to remote peer func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string, bodyType sProto.Body_Type) error { var sessionIDBytes []byte @@ -57,6 +66,7 @@ func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string, msg, err := signal.MarshalCredential(s.wgPrivateKey, remoteKey, signal.CredentialPayload{ Type: bodyType, WgListenPort: offerAnswer.WgListenPort, + FiledropPort: uint16(s.filedropPort.Load()), Credential: &signal.Credential{ UFrag: offerAnswer.IceCredentials.UFrag, Pwd: offerAnswer.IceCredentials.Pwd, diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index b438a310a..a0f8d61d7 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v6.33.1 +// protoc v7.34.1 // source: daemon.proto package proto @@ -143,6 +143,215 @@ func (ExposeProtocol) EnumDescriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{1} } +// FileDropMode is the device-local policy for incoming file offers. +type FileDropMode int32 + +const ( + FileDropMode_FILE_DROP_MODE_OFF FileDropMode = 0 + FileDropMode_FILE_DROP_MODE_ASK FileDropMode = 1 + FileDropMode_FILE_DROP_MODE_AUTO FileDropMode = 2 +) + +// Enum value maps for FileDropMode. +var ( + FileDropMode_name = map[int32]string{ + 0: "FILE_DROP_MODE_OFF", + 1: "FILE_DROP_MODE_ASK", + 2: "FILE_DROP_MODE_AUTO", + } + FileDropMode_value = map[string]int32{ + "FILE_DROP_MODE_OFF": 0, + "FILE_DROP_MODE_ASK": 1, + "FILE_DROP_MODE_AUTO": 2, + } +) + +func (x FileDropMode) Enum() *FileDropMode { + p := new(FileDropMode) + *p = x + return p +} + +func (x FileDropMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FileDropMode) Descriptor() protoreflect.EnumDescriptor { + return file_daemon_proto_enumTypes[2].Descriptor() +} + +func (FileDropMode) Type() protoreflect.EnumType { + return &file_daemon_proto_enumTypes[2] +} + +func (x FileDropMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FileDropMode.Descriptor instead. +func (FileDropMode) EnumDescriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{2} +} + +// FileDropRule is a per-sender exception on top of the base mode. +type FileDropRule int32 + +const ( + FileDropRule_FILE_DROP_RULE_DEFAULT FileDropRule = 0 + FileDropRule_FILE_DROP_RULE_ALWAYS FileDropRule = 1 + FileDropRule_FILE_DROP_RULE_BLOCK FileDropRule = 2 +) + +// Enum value maps for FileDropRule. +var ( + FileDropRule_name = map[int32]string{ + 0: "FILE_DROP_RULE_DEFAULT", + 1: "FILE_DROP_RULE_ALWAYS", + 2: "FILE_DROP_RULE_BLOCK", + } + FileDropRule_value = map[string]int32{ + "FILE_DROP_RULE_DEFAULT": 0, + "FILE_DROP_RULE_ALWAYS": 1, + "FILE_DROP_RULE_BLOCK": 2, + } +) + +func (x FileDropRule) Enum() *FileDropRule { + p := new(FileDropRule) + *p = x + return p +} + +func (x FileDropRule) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FileDropRule) Descriptor() protoreflect.EnumDescriptor { + return file_daemon_proto_enumTypes[3].Descriptor() +} + +func (FileDropRule) Type() protoreflect.EnumType { + return &file_daemon_proto_enumTypes[3] +} + +func (x FileDropRule) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FileDropRule.Descriptor instead. +func (FileDropRule) EnumDescriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{3} +} + +// FileDropReason classifies why a transfer failed, when it is known. +type FileDropReason int32 + +const ( + FileDropReason_FILE_DROP_REASON_NONE FileDropReason = 0 + FileDropReason_FILE_DROP_REASON_UNREACHABLE FileDropReason = 1 +) + +// Enum value maps for FileDropReason. +var ( + FileDropReason_name = map[int32]string{ + 0: "FILE_DROP_REASON_NONE", + 1: "FILE_DROP_REASON_UNREACHABLE", + } + FileDropReason_value = map[string]int32{ + "FILE_DROP_REASON_NONE": 0, + "FILE_DROP_REASON_UNREACHABLE": 1, + } +) + +func (x FileDropReason) Enum() *FileDropReason { + p := new(FileDropReason) + *p = x + return p +} + +func (x FileDropReason) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FileDropReason) Descriptor() protoreflect.EnumDescriptor { + return file_daemon_proto_enumTypes[4].Descriptor() +} + +func (FileDropReason) Type() protoreflect.EnumType { + return &file_daemon_proto_enumTypes[4] +} + +func (x FileDropReason) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FileDropReason.Descriptor instead. +func (FileDropReason) EnumDescriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{4} +} + +// FileDropState is the lifecycle state of a transfer. +type FileDropState int32 + +const ( + FileDropState_FILE_DROP_STATE_PENDING FileDropState = 0 + FileDropState_FILE_DROP_STATE_TRANSFERRING FileDropState = 1 + FileDropState_FILE_DROP_STATE_COMPLETED FileDropState = 2 + FileDropState_FILE_DROP_STATE_DECLINED FileDropState = 3 + FileDropState_FILE_DROP_STATE_EXPIRED FileDropState = 4 + FileDropState_FILE_DROP_STATE_CANCELLED FileDropState = 5 + FileDropState_FILE_DROP_STATE_FAILED FileDropState = 6 +) + +// Enum value maps for FileDropState. +var ( + FileDropState_name = map[int32]string{ + 0: "FILE_DROP_STATE_PENDING", + 1: "FILE_DROP_STATE_TRANSFERRING", + 2: "FILE_DROP_STATE_COMPLETED", + 3: "FILE_DROP_STATE_DECLINED", + 4: "FILE_DROP_STATE_EXPIRED", + 5: "FILE_DROP_STATE_CANCELLED", + 6: "FILE_DROP_STATE_FAILED", + } + FileDropState_value = map[string]int32{ + "FILE_DROP_STATE_PENDING": 0, + "FILE_DROP_STATE_TRANSFERRING": 1, + "FILE_DROP_STATE_COMPLETED": 2, + "FILE_DROP_STATE_DECLINED": 3, + "FILE_DROP_STATE_EXPIRED": 4, + "FILE_DROP_STATE_CANCELLED": 5, + "FILE_DROP_STATE_FAILED": 6, + } +) + +func (x FileDropState) Enum() *FileDropState { + p := new(FileDropState) + *p = x + return p +} + +func (x FileDropState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FileDropState) Descriptor() protoreflect.EnumDescriptor { + return file_daemon_proto_enumTypes[5].Descriptor() +} + +func (FileDropState) Type() protoreflect.EnumType { + return &file_daemon_proto_enumTypes[5] +} + +func (x FileDropState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FileDropState.Descriptor instead. +func (FileDropState) EnumDescriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{5} +} + type SystemEvent_Severity int32 const ( @@ -179,11 +388,11 @@ func (x SystemEvent_Severity) String() string { } func (SystemEvent_Severity) Descriptor() protoreflect.EnumDescriptor { - return file_daemon_proto_enumTypes[2].Descriptor() + return file_daemon_proto_enumTypes[6].Descriptor() } func (SystemEvent_Severity) Type() protoreflect.EnumType { - return &file_daemon_proto_enumTypes[2] + return &file_daemon_proto_enumTypes[6] } func (x SystemEvent_Severity) Number() protoreflect.EnumNumber { @@ -234,11 +443,11 @@ func (x SystemEvent_Category) String() string { } func (SystemEvent_Category) Descriptor() protoreflect.EnumDescriptor { - return file_daemon_proto_enumTypes[3].Descriptor() + return file_daemon_proto_enumTypes[7].Descriptor() } func (SystemEvent_Category) Type() protoreflect.EnumType { - return &file_daemon_proto_enumTypes[3] + return &file_daemon_proto_enumTypes[7] } func (x SystemEvent_Category) Number() protoreflect.EnumNumber { @@ -6975,6 +7184,930 @@ func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{106} } +type FileDropFile struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` + ContentType string `protobuf:"bytes,3,opt,name=contentType,proto3" json:"contentType,omitempty"` + IsText bool `protobuf:"varint,4,opt,name=isText,proto3" json:"isText,omitempty"` + // text carries an inline snippet; it never becomes a file on disk. + Text string `protobuf:"bytes,5,opt,name=text,proto3" json:"text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropFile) Reset() { + *x = FileDropFile{} + mi := &file_daemon_proto_msgTypes[107] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropFile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropFile) ProtoMessage() {} + +func (x *FileDropFile) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[107] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropFile.ProtoReflect.Descriptor instead. +func (*FileDropFile) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{107} +} + +func (x *FileDropFile) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FileDropFile) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *FileDropFile) GetContentType() string { + if x != nil { + return x.ContentType + } + return "" +} + +func (x *FileDropFile) GetIsText() bool { + if x != nil { + return x.IsText + } + return false +} + +func (x *FileDropFile) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +type FileDropTransfer struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Outgoing bool `protobuf:"varint,2,opt,name=outgoing,proto3" json:"outgoing,omitempty"` + PeerKey string `protobuf:"bytes,3,opt,name=peerKey,proto3" json:"peerKey,omitempty"` + PeerName string `protobuf:"bytes,4,opt,name=peerName,proto3" json:"peerName,omitempty"` + Files []*FileDropFile `protobuf:"bytes,5,rep,name=files,proto3" json:"files,omitempty"` + State FileDropState `protobuf:"varint,6,opt,name=state,proto3,enum=daemon.FileDropState" json:"state,omitempty"` + Transferred int64 `protobuf:"varint,7,opt,name=transferred,proto3" json:"transferred,omitempty"` + TotalSize int64 `protobuf:"varint,8,opt,name=totalSize,proto3" json:"totalSize,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=createdAt,proto3" json:"createdAt,omitempty"` + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=updatedAt,proto3" json:"updatedAt,omitempty"` + DeliveredPaths []string `protobuf:"bytes,11,rep,name=deliveredPaths,proto3" json:"deliveredPaths,omitempty"` + Error string `protobuf:"bytes,12,opt,name=error,proto3" json:"error,omitempty"` + Reason FileDropReason `protobuf:"varint,13,opt,name=reason,proto3,enum=daemon.FileDropReason" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropTransfer) Reset() { + *x = FileDropTransfer{} + mi := &file_daemon_proto_msgTypes[108] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropTransfer) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropTransfer) ProtoMessage() {} + +func (x *FileDropTransfer) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[108] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropTransfer.ProtoReflect.Descriptor instead. +func (*FileDropTransfer) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{108} +} + +func (x *FileDropTransfer) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *FileDropTransfer) GetOutgoing() bool { + if x != nil { + return x.Outgoing + } + return false +} + +func (x *FileDropTransfer) GetPeerKey() string { + if x != nil { + return x.PeerKey + } + return "" +} + +func (x *FileDropTransfer) GetPeerName() string { + if x != nil { + return x.PeerName + } + return "" +} + +func (x *FileDropTransfer) GetFiles() []*FileDropFile { + if x != nil { + return x.Files + } + return nil +} + +func (x *FileDropTransfer) GetState() FileDropState { + if x != nil { + return x.State + } + return FileDropState_FILE_DROP_STATE_PENDING +} + +func (x *FileDropTransfer) GetTransferred() int64 { + if x != nil { + return x.Transferred + } + return 0 +} + +func (x *FileDropTransfer) GetTotalSize() int64 { + if x != nil { + return x.TotalSize + } + return 0 +} + +func (x *FileDropTransfer) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *FileDropTransfer) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + +func (x *FileDropTransfer) GetDeliveredPaths() []string { + if x != nil { + return x.DeliveredPaths + } + return nil +} + +func (x *FileDropTransfer) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *FileDropTransfer) GetReason() FileDropReason { + if x != nil { + return x.Reason + } + return FileDropReason_FILE_DROP_REASON_NONE +} + +type FileDropSendRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + PeerKey string `protobuf:"bytes,1,opt,name=peerKey,proto3" json:"peerKey,omitempty"` + // paths are local files to send; the daemon opens them as the caller. + Paths []string `protobuf:"bytes,2,rep,name=paths,proto3" json:"paths,omitempty"` + // text is an inline snippet payload, sent instead of or alongside files. + Text string `protobuf:"bytes,3,opt,name=text,proto3" json:"text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropSendRequest) Reset() { + *x = FileDropSendRequest{} + mi := &file_daemon_proto_msgTypes[109] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropSendRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropSendRequest) ProtoMessage() {} + +func (x *FileDropSendRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[109] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropSendRequest.ProtoReflect.Descriptor instead. +func (*FileDropSendRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{109} +} + +func (x *FileDropSendRequest) GetPeerKey() string { + if x != nil { + return x.PeerKey + } + return "" +} + +func (x *FileDropSendRequest) GetPaths() []string { + if x != nil { + return x.Paths + } + return nil +} + +func (x *FileDropSendRequest) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +type FileDropSendResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + TransferId string `protobuf:"bytes,1,opt,name=transferId,proto3" json:"transferId,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropSendResponse) Reset() { + *x = FileDropSendResponse{} + mi := &file_daemon_proto_msgTypes[110] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropSendResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropSendResponse) ProtoMessage() {} + +func (x *FileDropSendResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[110] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropSendResponse.ProtoReflect.Descriptor instead. +func (*FileDropSendResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{110} +} + +func (x *FileDropSendResponse) GetTransferId() string { + if x != nil { + return x.TransferId + } + return "" +} + +type FileDropDecideRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TransferId string `protobuf:"bytes,1,opt,name=transferId,proto3" json:"transferId,omitempty"` + Accept bool `protobuf:"varint,2,opt,name=accept,proto3" json:"accept,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropDecideRequest) Reset() { + *x = FileDropDecideRequest{} + mi := &file_daemon_proto_msgTypes[111] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropDecideRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropDecideRequest) ProtoMessage() {} + +func (x *FileDropDecideRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[111] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropDecideRequest.ProtoReflect.Descriptor instead. +func (*FileDropDecideRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{111} +} + +func (x *FileDropDecideRequest) GetTransferId() string { + if x != nil { + return x.TransferId + } + return "" +} + +func (x *FileDropDecideRequest) GetAccept() bool { + if x != nil { + return x.Accept + } + return false +} + +type FileDropDecideResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropDecideResponse) Reset() { + *x = FileDropDecideResponse{} + mi := &file_daemon_proto_msgTypes[112] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropDecideResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropDecideResponse) ProtoMessage() {} + +func (x *FileDropDecideResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[112] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropDecideResponse.ProtoReflect.Descriptor instead. +func (*FileDropDecideResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{112} +} + +type FileDropCancelRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TransferId string `protobuf:"bytes,1,opt,name=transferId,proto3" json:"transferId,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropCancelRequest) Reset() { + *x = FileDropCancelRequest{} + mi := &file_daemon_proto_msgTypes[113] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropCancelRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropCancelRequest) ProtoMessage() {} + +func (x *FileDropCancelRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[113] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropCancelRequest.ProtoReflect.Descriptor instead. +func (*FileDropCancelRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{113} +} + +func (x *FileDropCancelRequest) GetTransferId() string { + if x != nil { + return x.TransferId + } + return "" +} + +type FileDropCancelResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropCancelResponse) Reset() { + *x = FileDropCancelResponse{} + mi := &file_daemon_proto_msgTypes[114] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropCancelResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropCancelResponse) ProtoMessage() {} + +func (x *FileDropCancelResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[114] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropCancelResponse.ProtoReflect.Descriptor instead. +func (*FileDropCancelResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{114} +} + +type FileDropListTransfersRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropListTransfersRequest) Reset() { + *x = FileDropListTransfersRequest{} + mi := &file_daemon_proto_msgTypes[115] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropListTransfersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropListTransfersRequest) ProtoMessage() {} + +func (x *FileDropListTransfersRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[115] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropListTransfersRequest.ProtoReflect.Descriptor instead. +func (*FileDropListTransfersRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{115} +} + +type FileDropListTransfersResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Transfers []*FileDropTransfer `protobuf:"bytes,1,rep,name=transfers,proto3" json:"transfers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropListTransfersResponse) Reset() { + *x = FileDropListTransfersResponse{} + mi := &file_daemon_proto_msgTypes[116] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropListTransfersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropListTransfersResponse) ProtoMessage() {} + +func (x *FileDropListTransfersResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[116] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropListTransfersResponse.ProtoReflect.Descriptor instead. +func (*FileDropListTransfersResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{116} +} + +func (x *FileDropListTransfersResponse) GetTransfers() []*FileDropTransfer { + if x != nil { + return x.Transfers + } + return nil +} + +type FileDropDeleteTransferRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TransferId string `protobuf:"bytes,1,opt,name=transferId,proto3" json:"transferId,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropDeleteTransferRequest) Reset() { + *x = FileDropDeleteTransferRequest{} + mi := &file_daemon_proto_msgTypes[117] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropDeleteTransferRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropDeleteTransferRequest) ProtoMessage() {} + +func (x *FileDropDeleteTransferRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[117] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropDeleteTransferRequest.ProtoReflect.Descriptor instead. +func (*FileDropDeleteTransferRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{117} +} + +func (x *FileDropDeleteTransferRequest) GetTransferId() string { + if x != nil { + return x.TransferId + } + return "" +} + +type FileDropDeleteTransferResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropDeleteTransferResponse) Reset() { + *x = FileDropDeleteTransferResponse{} + mi := &file_daemon_proto_msgTypes[118] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropDeleteTransferResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropDeleteTransferResponse) ProtoMessage() {} + +func (x *FileDropDeleteTransferResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[118] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropDeleteTransferResponse.ProtoReflect.Descriptor instead. +func (*FileDropDeleteTransferResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{118} +} + +type FileDropGetSettingsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropGetSettingsRequest) Reset() { + *x = FileDropGetSettingsRequest{} + mi := &file_daemon_proto_msgTypes[119] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropGetSettingsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropGetSettingsRequest) ProtoMessage() {} + +func (x *FileDropGetSettingsRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[119] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropGetSettingsRequest.ProtoReflect.Descriptor instead. +func (*FileDropGetSettingsRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{119} +} + +type FileDropGetSettingsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mode FileDropMode `protobuf:"varint,1,opt,name=mode,proto3,enum=daemon.FileDropMode" json:"mode,omitempty"` + DestinationDir string `protobuf:"bytes,2,opt,name=destinationDir,proto3" json:"destinationDir,omitempty"` + // peerRules is keyed by the peer's public key. + PeerRules map[string]FileDropRule `protobuf:"bytes,3,rep,name=peerRules,proto3" json:"peerRules,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value,enum=daemon.FileDropRule"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropGetSettingsResponse) Reset() { + *x = FileDropGetSettingsResponse{} + mi := &file_daemon_proto_msgTypes[120] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropGetSettingsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropGetSettingsResponse) ProtoMessage() {} + +func (x *FileDropGetSettingsResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[120] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropGetSettingsResponse.ProtoReflect.Descriptor instead. +func (*FileDropGetSettingsResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{120} +} + +func (x *FileDropGetSettingsResponse) GetMode() FileDropMode { + if x != nil { + return x.Mode + } + return FileDropMode_FILE_DROP_MODE_OFF +} + +func (x *FileDropGetSettingsResponse) GetDestinationDir() string { + if x != nil { + return x.DestinationDir + } + return "" +} + +func (x *FileDropGetSettingsResponse) GetPeerRules() map[string]FileDropRule { + if x != nil { + return x.PeerRules + } + return nil +} + +type FileDropSetSettingsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mode FileDropMode `protobuf:"varint,1,opt,name=mode,proto3,enum=daemon.FileDropMode" json:"mode,omitempty"` + DestinationDir string `protobuf:"bytes,2,opt,name=destinationDir,proto3" json:"destinationDir,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropSetSettingsRequest) Reset() { + *x = FileDropSetSettingsRequest{} + mi := &file_daemon_proto_msgTypes[121] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropSetSettingsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropSetSettingsRequest) ProtoMessage() {} + +func (x *FileDropSetSettingsRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[121] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropSetSettingsRequest.ProtoReflect.Descriptor instead. +func (*FileDropSetSettingsRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{121} +} + +func (x *FileDropSetSettingsRequest) GetMode() FileDropMode { + if x != nil { + return x.Mode + } + return FileDropMode_FILE_DROP_MODE_OFF +} + +func (x *FileDropSetSettingsRequest) GetDestinationDir() string { + if x != nil { + return x.DestinationDir + } + return "" +} + +type FileDropSetSettingsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropSetSettingsResponse) Reset() { + *x = FileDropSetSettingsResponse{} + mi := &file_daemon_proto_msgTypes[122] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropSetSettingsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropSetSettingsResponse) ProtoMessage() {} + +func (x *FileDropSetSettingsResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[122] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropSetSettingsResponse.ProtoReflect.Descriptor instead. +func (*FileDropSetSettingsResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{122} +} + +type FileDropSetPeerRuleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + PeerKey string `protobuf:"bytes,1,opt,name=peerKey,proto3" json:"peerKey,omitempty"` + Rule FileDropRule `protobuf:"varint,2,opt,name=rule,proto3,enum=daemon.FileDropRule" json:"rule,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropSetPeerRuleRequest) Reset() { + *x = FileDropSetPeerRuleRequest{} + mi := &file_daemon_proto_msgTypes[123] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropSetPeerRuleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropSetPeerRuleRequest) ProtoMessage() {} + +func (x *FileDropSetPeerRuleRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[123] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropSetPeerRuleRequest.ProtoReflect.Descriptor instead. +func (*FileDropSetPeerRuleRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{123} +} + +func (x *FileDropSetPeerRuleRequest) GetPeerKey() string { + if x != nil { + return x.PeerKey + } + return "" +} + +func (x *FileDropSetPeerRuleRequest) GetRule() FileDropRule { + if x != nil { + return x.Rule + } + return FileDropRule_FILE_DROP_RULE_DEFAULT +} + +type FileDropSetPeerRuleResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileDropSetPeerRuleResponse) Reset() { + *x = FileDropSetPeerRuleResponse{} + mi := &file_daemon_proto_msgTypes[124] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileDropSetPeerRuleResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileDropSetPeerRuleResponse) ProtoMessage() {} + +func (x *FileDropSetPeerRuleResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[124] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileDropSetPeerRuleResponse.ProtoReflect.Descriptor instead. +func (*FileDropSetPeerRuleResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{124} +} + type PortInfo_Range struct { state protoimpl.MessageState `protogen:"open.v1"` Start uint32 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"` @@ -6985,7 +8118,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[108] + mi := &file_daemon_proto_msgTypes[126] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6997,7 +8130,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[108] + mi := &file_daemon_proto_msgTypes[126] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7614,7 +8747,71 @@ const file_daemon_proto_rawDesc = "" + "\atimeout\x18\x01 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\x1c\n" + "\x1aStartBundleCaptureResponse\"\x1a\n" + "\x18StopBundleCaptureRequest\"\x1b\n" + - "\x19StopBundleCaptureResponse*b\n" + + "\x19StopBundleCaptureResponse\"\x84\x01\n" + + "\fFileDropFile\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + + "\x04size\x18\x02 \x01(\x03R\x04size\x12 \n" + + "\vcontentType\x18\x03 \x01(\tR\vcontentType\x12\x16\n" + + "\x06isText\x18\x04 \x01(\bR\x06isText\x12\x12\n" + + "\x04text\x18\x05 \x01(\tR\x04text\"\xef\x03\n" + + "\x10FileDropTransfer\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n" + + "\boutgoing\x18\x02 \x01(\bR\boutgoing\x12\x18\n" + + "\apeerKey\x18\x03 \x01(\tR\apeerKey\x12\x1a\n" + + "\bpeerName\x18\x04 \x01(\tR\bpeerName\x12*\n" + + "\x05files\x18\x05 \x03(\v2\x14.daemon.FileDropFileR\x05files\x12+\n" + + "\x05state\x18\x06 \x01(\x0e2\x15.daemon.FileDropStateR\x05state\x12 \n" + + "\vtransferred\x18\a \x01(\x03R\vtransferred\x12\x1c\n" + + "\ttotalSize\x18\b \x01(\x03R\ttotalSize\x128\n" + + "\tcreatedAt\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x128\n" + + "\tupdatedAt\x18\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12&\n" + + "\x0edeliveredPaths\x18\v \x03(\tR\x0edeliveredPaths\x12\x14\n" + + "\x05error\x18\f \x01(\tR\x05error\x12.\n" + + "\x06reason\x18\r \x01(\x0e2\x16.daemon.FileDropReasonR\x06reason\"Y\n" + + "\x13FileDropSendRequest\x12\x18\n" + + "\apeerKey\x18\x01 \x01(\tR\apeerKey\x12\x14\n" + + "\x05paths\x18\x02 \x03(\tR\x05paths\x12\x12\n" + + "\x04text\x18\x03 \x01(\tR\x04text\"6\n" + + "\x14FileDropSendResponse\x12\x1e\n" + + "\n" + + "transferId\x18\x01 \x01(\tR\n" + + "transferId\"O\n" + + "\x15FileDropDecideRequest\x12\x1e\n" + + "\n" + + "transferId\x18\x01 \x01(\tR\n" + + "transferId\x12\x16\n" + + "\x06accept\x18\x02 \x01(\bR\x06accept\"\x18\n" + + "\x16FileDropDecideResponse\"7\n" + + "\x15FileDropCancelRequest\x12\x1e\n" + + "\n" + + "transferId\x18\x01 \x01(\tR\n" + + "transferId\"\x18\n" + + "\x16FileDropCancelResponse\"\x1e\n" + + "\x1cFileDropListTransfersRequest\"W\n" + + "\x1dFileDropListTransfersResponse\x126\n" + + "\ttransfers\x18\x01 \x03(\v2\x18.daemon.FileDropTransferR\ttransfers\"?\n" + + "\x1dFileDropDeleteTransferRequest\x12\x1e\n" + + "\n" + + "transferId\x18\x01 \x01(\tR\n" + + "transferId\" \n" + + "\x1eFileDropDeleteTransferResponse\"\x1c\n" + + "\x1aFileDropGetSettingsRequest\"\x95\x02\n" + + "\x1bFileDropGetSettingsResponse\x12(\n" + + "\x04mode\x18\x01 \x01(\x0e2\x14.daemon.FileDropModeR\x04mode\x12&\n" + + "\x0edestinationDir\x18\x02 \x01(\tR\x0edestinationDir\x12P\n" + + "\tpeerRules\x18\x03 \x03(\v22.daemon.FileDropGetSettingsResponse.PeerRulesEntryR\tpeerRules\x1aR\n" + + "\x0ePeerRulesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" + + "\x05value\x18\x02 \x01(\x0e2\x14.daemon.FileDropRuleR\x05value:\x028\x01\"n\n" + + "\x1aFileDropSetSettingsRequest\x12(\n" + + "\x04mode\x18\x01 \x01(\x0e2\x14.daemon.FileDropModeR\x04mode\x12&\n" + + "\x0edestinationDir\x18\x02 \x01(\tR\x0edestinationDir\"\x1d\n" + + "\x1bFileDropSetSettingsResponse\"`\n" + + "\x1aFileDropSetPeerRuleRequest\x12\x18\n" + + "\apeerKey\x18\x01 \x01(\tR\apeerKey\x12(\n" + + "\x04rule\x18\x02 \x01(\x0e2\x14.daemon.FileDropRuleR\x04rule\"\x1d\n" + + "\x1bFileDropSetPeerRuleResponse*b\n" + "\bLogLevel\x12\v\n" + "\aUNKNOWN\x10\x00\x12\t\n" + "\x05PANIC\x10\x01\x12\t\n" + @@ -7632,7 +8829,26 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xa3\x1c\n" + + "EXPOSE_TLS\x10\x04*W\n" + + "\fFileDropMode\x12\x16\n" + + "\x12FILE_DROP_MODE_OFF\x10\x00\x12\x16\n" + + "\x12FILE_DROP_MODE_ASK\x10\x01\x12\x17\n" + + "\x13FILE_DROP_MODE_AUTO\x10\x02*_\n" + + "\fFileDropRule\x12\x1a\n" + + "\x16FILE_DROP_RULE_DEFAULT\x10\x00\x12\x19\n" + + "\x15FILE_DROP_RULE_ALWAYS\x10\x01\x12\x18\n" + + "\x14FILE_DROP_RULE_BLOCK\x10\x02*M\n" + + "\x0eFileDropReason\x12\x19\n" + + "\x15FILE_DROP_REASON_NONE\x10\x00\x12 \n" + + "\x1cFILE_DROP_REASON_UNREACHABLE\x10\x01*\xe3\x01\n" + + "\rFileDropState\x12\x1b\n" + + "\x17FILE_DROP_STATE_PENDING\x10\x00\x12 \n" + + "\x1cFILE_DROP_STATE_TRANSFERRING\x10\x01\x12\x1d\n" + + "\x19FILE_DROP_STATE_COMPLETED\x10\x02\x12\x1c\n" + + "\x18FILE_DROP_STATE_DECLINED\x10\x03\x12\x1b\n" + + "\x17FILE_DROP_STATE_EXPIRED\x10\x04\x12\x1d\n" + + "\x19FILE_DROP_STATE_CANCELLED\x10\x05\x12\x1a\n" + + "\x16FILE_DROP_STATE_FAILED\x10\x062\x8f\"\n" + "\rDaemonService\x126\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + @@ -7682,7 +8898,15 @@ const file_daemon_proto_rawDesc = "" + "\x0eStopCPUProfile\x12\x1d.daemon.StopCPUProfileRequest\x1a\x1e.daemon.StopCPUProfileResponse\"\x00\x12W\n" + "\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00\x12M\n" + "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01\x12K\n" + - "\fWailsUIReady\x12\x1b.daemon.WailsUIReadyRequest\x1a\x1c.daemon.WailsUIReadyResponse\"\x00B\bZ\x06/protob\x06proto3" + "\fWailsUIReady\x12\x1b.daemon.WailsUIReadyRequest\x1a\x1c.daemon.WailsUIReadyResponse\"\x00\x12K\n" + + "\fFileDropSend\x12\x1b.daemon.FileDropSendRequest\x1a\x1c.daemon.FileDropSendResponse\"\x00\x12Q\n" + + "\x0eFileDropDecide\x12\x1d.daemon.FileDropDecideRequest\x1a\x1e.daemon.FileDropDecideResponse\"\x00\x12Q\n" + + "\x0eFileDropCancel\x12\x1d.daemon.FileDropCancelRequest\x1a\x1e.daemon.FileDropCancelResponse\"\x00\x12f\n" + + "\x15FileDropListTransfers\x12$.daemon.FileDropListTransfersRequest\x1a%.daemon.FileDropListTransfersResponse\"\x00\x12i\n" + + "\x16FileDropDeleteTransfer\x12%.daemon.FileDropDeleteTransferRequest\x1a&.daemon.FileDropDeleteTransferResponse\"\x00\x12`\n" + + "\x13FileDropGetSettings\x12\".daemon.FileDropGetSettingsRequest\x1a#.daemon.FileDropGetSettingsResponse\"\x00\x12`\n" + + "\x13FileDropSetSettings\x12\".daemon.FileDropSetSettingsRequest\x1a#.daemon.FileDropSetSettingsResponse\"\x00\x12`\n" + + "\x13FileDropSetPeerRule\x12\".daemon.FileDropSetPeerRuleRequest\x1a#.daemon.FileDropSetPeerRuleResponse\"\x00B\bZ\x06/protob\x06proto3" var ( file_daemon_proto_rawDescOnce sync.Once @@ -7696,263 +8920,313 @@ func file_daemon_proto_rawDescGZIP() []byte { return file_daemon_proto_rawDescData } -var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 110) +var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 8) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 129) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol - (SystemEvent_Severity)(0), // 2: daemon.SystemEvent.Severity - (SystemEvent_Category)(0), // 3: daemon.SystemEvent.Category - (*EmptyRequest)(nil), // 4: daemon.EmptyRequest - (*LoginRequest)(nil), // 5: daemon.LoginRequest - (*LoginResponse)(nil), // 6: daemon.LoginResponse - (*WaitSSOLoginRequest)(nil), // 7: daemon.WaitSSOLoginRequest - (*WaitSSOLoginResponse)(nil), // 8: daemon.WaitSSOLoginResponse - (*UpRequest)(nil), // 9: daemon.UpRequest - (*UpResponse)(nil), // 10: daemon.UpResponse - (*StatusRequest)(nil), // 11: daemon.StatusRequest - (*StatusResponse)(nil), // 12: daemon.StatusResponse - (*DownRequest)(nil), // 13: daemon.DownRequest - (*DownResponse)(nil), // 14: daemon.DownResponse - (*GetConfigRequest)(nil), // 15: daemon.GetConfigRequest - (*GetConfigResponse)(nil), // 16: daemon.GetConfigResponse - (*PeerState)(nil), // 17: daemon.PeerState - (*LocalPeerState)(nil), // 18: daemon.LocalPeerState - (*SignalState)(nil), // 19: daemon.SignalState - (*ManagementState)(nil), // 20: daemon.ManagementState - (*RelayState)(nil), // 21: daemon.RelayState - (*NSGroupState)(nil), // 22: daemon.NSGroupState - (*SSHSessionInfo)(nil), // 23: daemon.SSHSessionInfo - (*SSHServerState)(nil), // 24: daemon.SSHServerState - (*FullStatus)(nil), // 25: daemon.FullStatus - (*ListNetworksRequest)(nil), // 26: daemon.ListNetworksRequest - (*ListNetworksResponse)(nil), // 27: daemon.ListNetworksResponse - (*SelectNetworksRequest)(nil), // 28: daemon.SelectNetworksRequest - (*SelectNetworksResponse)(nil), // 29: daemon.SelectNetworksResponse - (*IPList)(nil), // 30: daemon.IPList - (*Network)(nil), // 31: daemon.Network - (*PortInfo)(nil), // 32: daemon.PortInfo - (*ForwardingRule)(nil), // 33: daemon.ForwardingRule - (*ForwardingRulesResponse)(nil), // 34: daemon.ForwardingRulesResponse - (*DebugBundleRequest)(nil), // 35: daemon.DebugBundleRequest - (*DebugBundleResponse)(nil), // 36: daemon.DebugBundleResponse - (*GetLogLevelRequest)(nil), // 37: daemon.GetLogLevelRequest - (*GetLogLevelResponse)(nil), // 38: daemon.GetLogLevelResponse - (*SetLogLevelRequest)(nil), // 39: daemon.SetLogLevelRequest - (*SetLogLevelResponse)(nil), // 40: daemon.SetLogLevelResponse - (*RegisterUILogRequest)(nil), // 41: daemon.RegisterUILogRequest - (*RegisterUILogResponse)(nil), // 42: daemon.RegisterUILogResponse - (*State)(nil), // 43: daemon.State - (*ListStatesRequest)(nil), // 44: daemon.ListStatesRequest - (*ListStatesResponse)(nil), // 45: daemon.ListStatesResponse - (*CleanStateRequest)(nil), // 46: daemon.CleanStateRequest - (*CleanStateResponse)(nil), // 47: daemon.CleanStateResponse - (*DeleteStateRequest)(nil), // 48: daemon.DeleteStateRequest - (*DeleteStateResponse)(nil), // 49: daemon.DeleteStateResponse - (*SetSyncResponsePersistenceRequest)(nil), // 50: daemon.SetSyncResponsePersistenceRequest - (*SetSyncResponsePersistenceResponse)(nil), // 51: daemon.SetSyncResponsePersistenceResponse - (*TCPFlags)(nil), // 52: daemon.TCPFlags - (*TracePacketRequest)(nil), // 53: daemon.TracePacketRequest - (*TraceStage)(nil), // 54: daemon.TraceStage - (*TracePacketResponse)(nil), // 55: daemon.TracePacketResponse - (*SubscribeRequest)(nil), // 56: daemon.SubscribeRequest - (*SystemEvent)(nil), // 57: daemon.SystemEvent - (*GetEventsRequest)(nil), // 58: daemon.GetEventsRequest - (*GetEventsResponse)(nil), // 59: daemon.GetEventsResponse - (*SwitchProfileRequest)(nil), // 60: daemon.SwitchProfileRequest - (*SwitchProfileResponse)(nil), // 61: daemon.SwitchProfileResponse - (*SetConfigRequest)(nil), // 62: daemon.SetConfigRequest - (*SetConfigResponse)(nil), // 63: daemon.SetConfigResponse - (*AddProfileRequest)(nil), // 64: daemon.AddProfileRequest - (*AddProfileResponse)(nil), // 65: daemon.AddProfileResponse - (*RenameProfileRequest)(nil), // 66: daemon.RenameProfileRequest - (*RenameProfileResponse)(nil), // 67: daemon.RenameProfileResponse - (*RemoveProfileRequest)(nil), // 68: daemon.RemoveProfileRequest - (*RemoveProfileResponse)(nil), // 69: daemon.RemoveProfileResponse - (*ListProfilesRequest)(nil), // 70: daemon.ListProfilesRequest - (*ListProfilesResponse)(nil), // 71: daemon.ListProfilesResponse - (*Profile)(nil), // 72: daemon.Profile - (*GetActiveProfileRequest)(nil), // 73: daemon.GetActiveProfileRequest - (*GetActiveProfileResponse)(nil), // 74: daemon.GetActiveProfileResponse - (*LogoutRequest)(nil), // 75: daemon.LogoutRequest - (*LogoutResponse)(nil), // 76: daemon.LogoutResponse - (*WailsUIReadyRequest)(nil), // 77: daemon.WailsUIReadyRequest - (*WailsUIReadyResponse)(nil), // 78: daemon.WailsUIReadyResponse - (*GetFeaturesRequest)(nil), // 79: daemon.GetFeaturesRequest - (*GetFeaturesResponse)(nil), // 80: daemon.GetFeaturesResponse - (*MDMManagedFieldsViolation)(nil), // 81: daemon.MDMManagedFieldsViolation - (*TriggerUpdateRequest)(nil), // 82: daemon.TriggerUpdateRequest - (*TriggerUpdateResponse)(nil), // 83: daemon.TriggerUpdateResponse - (*GetPeerSSHHostKeyRequest)(nil), // 84: daemon.GetPeerSSHHostKeyRequest - (*GetPeerSSHHostKeyResponse)(nil), // 85: daemon.GetPeerSSHHostKeyResponse - (*RequestJWTAuthRequest)(nil), // 86: daemon.RequestJWTAuthRequest - (*RequestJWTAuthResponse)(nil), // 87: daemon.RequestJWTAuthResponse - (*WaitJWTTokenRequest)(nil), // 88: daemon.WaitJWTTokenRequest - (*WaitJWTTokenResponse)(nil), // 89: daemon.WaitJWTTokenResponse - (*RequestExtendAuthSessionRequest)(nil), // 90: daemon.RequestExtendAuthSessionRequest - (*RequestExtendAuthSessionResponse)(nil), // 91: daemon.RequestExtendAuthSessionResponse - (*WaitExtendAuthSessionRequest)(nil), // 92: daemon.WaitExtendAuthSessionRequest - (*WaitExtendAuthSessionResponse)(nil), // 93: daemon.WaitExtendAuthSessionResponse - (*DismissSessionWarningRequest)(nil), // 94: daemon.DismissSessionWarningRequest - (*DismissSessionWarningResponse)(nil), // 95: daemon.DismissSessionWarningResponse - (*StartCPUProfileRequest)(nil), // 96: daemon.StartCPUProfileRequest - (*StartCPUProfileResponse)(nil), // 97: daemon.StartCPUProfileResponse - (*StopCPUProfileRequest)(nil), // 98: daemon.StopCPUProfileRequest - (*StopCPUProfileResponse)(nil), // 99: daemon.StopCPUProfileResponse - (*InstallerResultRequest)(nil), // 100: daemon.InstallerResultRequest - (*InstallerResultResponse)(nil), // 101: daemon.InstallerResultResponse - (*ExposeServiceRequest)(nil), // 102: daemon.ExposeServiceRequest - (*ExposeServiceEvent)(nil), // 103: daemon.ExposeServiceEvent - (*ExposeServiceReady)(nil), // 104: daemon.ExposeServiceReady - (*StartCaptureRequest)(nil), // 105: daemon.StartCaptureRequest - (*CapturePacket)(nil), // 106: daemon.CapturePacket - (*StartBundleCaptureRequest)(nil), // 107: daemon.StartBundleCaptureRequest - (*StartBundleCaptureResponse)(nil), // 108: daemon.StartBundleCaptureResponse - (*StopBundleCaptureRequest)(nil), // 109: daemon.StopBundleCaptureRequest - (*StopBundleCaptureResponse)(nil), // 110: daemon.StopBundleCaptureResponse - nil, // 111: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 112: daemon.PortInfo.Range - nil, // 113: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 114: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 115: google.protobuf.Timestamp + (FileDropMode)(0), // 2: daemon.FileDropMode + (FileDropRule)(0), // 3: daemon.FileDropRule + (FileDropReason)(0), // 4: daemon.FileDropReason + (FileDropState)(0), // 5: daemon.FileDropState + (SystemEvent_Severity)(0), // 6: daemon.SystemEvent.Severity + (SystemEvent_Category)(0), // 7: daemon.SystemEvent.Category + (*EmptyRequest)(nil), // 8: daemon.EmptyRequest + (*LoginRequest)(nil), // 9: daemon.LoginRequest + (*LoginResponse)(nil), // 10: daemon.LoginResponse + (*WaitSSOLoginRequest)(nil), // 11: daemon.WaitSSOLoginRequest + (*WaitSSOLoginResponse)(nil), // 12: daemon.WaitSSOLoginResponse + (*UpRequest)(nil), // 13: daemon.UpRequest + (*UpResponse)(nil), // 14: daemon.UpResponse + (*StatusRequest)(nil), // 15: daemon.StatusRequest + (*StatusResponse)(nil), // 16: daemon.StatusResponse + (*DownRequest)(nil), // 17: daemon.DownRequest + (*DownResponse)(nil), // 18: daemon.DownResponse + (*GetConfigRequest)(nil), // 19: daemon.GetConfigRequest + (*GetConfigResponse)(nil), // 20: daemon.GetConfigResponse + (*PeerState)(nil), // 21: daemon.PeerState + (*LocalPeerState)(nil), // 22: daemon.LocalPeerState + (*SignalState)(nil), // 23: daemon.SignalState + (*ManagementState)(nil), // 24: daemon.ManagementState + (*RelayState)(nil), // 25: daemon.RelayState + (*NSGroupState)(nil), // 26: daemon.NSGroupState + (*SSHSessionInfo)(nil), // 27: daemon.SSHSessionInfo + (*SSHServerState)(nil), // 28: daemon.SSHServerState + (*FullStatus)(nil), // 29: daemon.FullStatus + (*ListNetworksRequest)(nil), // 30: daemon.ListNetworksRequest + (*ListNetworksResponse)(nil), // 31: daemon.ListNetworksResponse + (*SelectNetworksRequest)(nil), // 32: daemon.SelectNetworksRequest + (*SelectNetworksResponse)(nil), // 33: daemon.SelectNetworksResponse + (*IPList)(nil), // 34: daemon.IPList + (*Network)(nil), // 35: daemon.Network + (*PortInfo)(nil), // 36: daemon.PortInfo + (*ForwardingRule)(nil), // 37: daemon.ForwardingRule + (*ForwardingRulesResponse)(nil), // 38: daemon.ForwardingRulesResponse + (*DebugBundleRequest)(nil), // 39: daemon.DebugBundleRequest + (*DebugBundleResponse)(nil), // 40: daemon.DebugBundleResponse + (*GetLogLevelRequest)(nil), // 41: daemon.GetLogLevelRequest + (*GetLogLevelResponse)(nil), // 42: daemon.GetLogLevelResponse + (*SetLogLevelRequest)(nil), // 43: daemon.SetLogLevelRequest + (*SetLogLevelResponse)(nil), // 44: daemon.SetLogLevelResponse + (*RegisterUILogRequest)(nil), // 45: daemon.RegisterUILogRequest + (*RegisterUILogResponse)(nil), // 46: daemon.RegisterUILogResponse + (*State)(nil), // 47: daemon.State + (*ListStatesRequest)(nil), // 48: daemon.ListStatesRequest + (*ListStatesResponse)(nil), // 49: daemon.ListStatesResponse + (*CleanStateRequest)(nil), // 50: daemon.CleanStateRequest + (*CleanStateResponse)(nil), // 51: daemon.CleanStateResponse + (*DeleteStateRequest)(nil), // 52: daemon.DeleteStateRequest + (*DeleteStateResponse)(nil), // 53: daemon.DeleteStateResponse + (*SetSyncResponsePersistenceRequest)(nil), // 54: daemon.SetSyncResponsePersistenceRequest + (*SetSyncResponsePersistenceResponse)(nil), // 55: daemon.SetSyncResponsePersistenceResponse + (*TCPFlags)(nil), // 56: daemon.TCPFlags + (*TracePacketRequest)(nil), // 57: daemon.TracePacketRequest + (*TraceStage)(nil), // 58: daemon.TraceStage + (*TracePacketResponse)(nil), // 59: daemon.TracePacketResponse + (*SubscribeRequest)(nil), // 60: daemon.SubscribeRequest + (*SystemEvent)(nil), // 61: daemon.SystemEvent + (*GetEventsRequest)(nil), // 62: daemon.GetEventsRequest + (*GetEventsResponse)(nil), // 63: daemon.GetEventsResponse + (*SwitchProfileRequest)(nil), // 64: daemon.SwitchProfileRequest + (*SwitchProfileResponse)(nil), // 65: daemon.SwitchProfileResponse + (*SetConfigRequest)(nil), // 66: daemon.SetConfigRequest + (*SetConfigResponse)(nil), // 67: daemon.SetConfigResponse + (*AddProfileRequest)(nil), // 68: daemon.AddProfileRequest + (*AddProfileResponse)(nil), // 69: daemon.AddProfileResponse + (*RenameProfileRequest)(nil), // 70: daemon.RenameProfileRequest + (*RenameProfileResponse)(nil), // 71: daemon.RenameProfileResponse + (*RemoveProfileRequest)(nil), // 72: daemon.RemoveProfileRequest + (*RemoveProfileResponse)(nil), // 73: daemon.RemoveProfileResponse + (*ListProfilesRequest)(nil), // 74: daemon.ListProfilesRequest + (*ListProfilesResponse)(nil), // 75: daemon.ListProfilesResponse + (*Profile)(nil), // 76: daemon.Profile + (*GetActiveProfileRequest)(nil), // 77: daemon.GetActiveProfileRequest + (*GetActiveProfileResponse)(nil), // 78: daemon.GetActiveProfileResponse + (*LogoutRequest)(nil), // 79: daemon.LogoutRequest + (*LogoutResponse)(nil), // 80: daemon.LogoutResponse + (*WailsUIReadyRequest)(nil), // 81: daemon.WailsUIReadyRequest + (*WailsUIReadyResponse)(nil), // 82: daemon.WailsUIReadyResponse + (*GetFeaturesRequest)(nil), // 83: daemon.GetFeaturesRequest + (*GetFeaturesResponse)(nil), // 84: daemon.GetFeaturesResponse + (*MDMManagedFieldsViolation)(nil), // 85: daemon.MDMManagedFieldsViolation + (*TriggerUpdateRequest)(nil), // 86: daemon.TriggerUpdateRequest + (*TriggerUpdateResponse)(nil), // 87: daemon.TriggerUpdateResponse + (*GetPeerSSHHostKeyRequest)(nil), // 88: daemon.GetPeerSSHHostKeyRequest + (*GetPeerSSHHostKeyResponse)(nil), // 89: daemon.GetPeerSSHHostKeyResponse + (*RequestJWTAuthRequest)(nil), // 90: daemon.RequestJWTAuthRequest + (*RequestJWTAuthResponse)(nil), // 91: daemon.RequestJWTAuthResponse + (*WaitJWTTokenRequest)(nil), // 92: daemon.WaitJWTTokenRequest + (*WaitJWTTokenResponse)(nil), // 93: daemon.WaitJWTTokenResponse + (*RequestExtendAuthSessionRequest)(nil), // 94: daemon.RequestExtendAuthSessionRequest + (*RequestExtendAuthSessionResponse)(nil), // 95: daemon.RequestExtendAuthSessionResponse + (*WaitExtendAuthSessionRequest)(nil), // 96: daemon.WaitExtendAuthSessionRequest + (*WaitExtendAuthSessionResponse)(nil), // 97: daemon.WaitExtendAuthSessionResponse + (*DismissSessionWarningRequest)(nil), // 98: daemon.DismissSessionWarningRequest + (*DismissSessionWarningResponse)(nil), // 99: daemon.DismissSessionWarningResponse + (*StartCPUProfileRequest)(nil), // 100: daemon.StartCPUProfileRequest + (*StartCPUProfileResponse)(nil), // 101: daemon.StartCPUProfileResponse + (*StopCPUProfileRequest)(nil), // 102: daemon.StopCPUProfileRequest + (*StopCPUProfileResponse)(nil), // 103: daemon.StopCPUProfileResponse + (*InstallerResultRequest)(nil), // 104: daemon.InstallerResultRequest + (*InstallerResultResponse)(nil), // 105: daemon.InstallerResultResponse + (*ExposeServiceRequest)(nil), // 106: daemon.ExposeServiceRequest + (*ExposeServiceEvent)(nil), // 107: daemon.ExposeServiceEvent + (*ExposeServiceReady)(nil), // 108: daemon.ExposeServiceReady + (*StartCaptureRequest)(nil), // 109: daemon.StartCaptureRequest + (*CapturePacket)(nil), // 110: daemon.CapturePacket + (*StartBundleCaptureRequest)(nil), // 111: daemon.StartBundleCaptureRequest + (*StartBundleCaptureResponse)(nil), // 112: daemon.StartBundleCaptureResponse + (*StopBundleCaptureRequest)(nil), // 113: daemon.StopBundleCaptureRequest + (*StopBundleCaptureResponse)(nil), // 114: daemon.StopBundleCaptureResponse + (*FileDropFile)(nil), // 115: daemon.FileDropFile + (*FileDropTransfer)(nil), // 116: daemon.FileDropTransfer + (*FileDropSendRequest)(nil), // 117: daemon.FileDropSendRequest + (*FileDropSendResponse)(nil), // 118: daemon.FileDropSendResponse + (*FileDropDecideRequest)(nil), // 119: daemon.FileDropDecideRequest + (*FileDropDecideResponse)(nil), // 120: daemon.FileDropDecideResponse + (*FileDropCancelRequest)(nil), // 121: daemon.FileDropCancelRequest + (*FileDropCancelResponse)(nil), // 122: daemon.FileDropCancelResponse + (*FileDropListTransfersRequest)(nil), // 123: daemon.FileDropListTransfersRequest + (*FileDropListTransfersResponse)(nil), // 124: daemon.FileDropListTransfersResponse + (*FileDropDeleteTransferRequest)(nil), // 125: daemon.FileDropDeleteTransferRequest + (*FileDropDeleteTransferResponse)(nil), // 126: daemon.FileDropDeleteTransferResponse + (*FileDropGetSettingsRequest)(nil), // 127: daemon.FileDropGetSettingsRequest + (*FileDropGetSettingsResponse)(nil), // 128: daemon.FileDropGetSettingsResponse + (*FileDropSetSettingsRequest)(nil), // 129: daemon.FileDropSetSettingsRequest + (*FileDropSetSettingsResponse)(nil), // 130: daemon.FileDropSetSettingsResponse + (*FileDropSetPeerRuleRequest)(nil), // 131: daemon.FileDropSetPeerRuleRequest + (*FileDropSetPeerRuleResponse)(nil), // 132: daemon.FileDropSetPeerRuleResponse + nil, // 133: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 134: daemon.PortInfo.Range + nil, // 135: daemon.SystemEvent.MetadataEntry + nil, // 136: daemon.FileDropGetSettingsResponse.PeerRulesEntry + (*durationpb.Duration)(nil), // 137: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 138: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 114, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 115, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp - 115, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 115, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 114, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration - 23, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo - 20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState - 19, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState - 18, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState - 17, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState - 21, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState - 22, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState - 57, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent - 24, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState - 31, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 111, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 112, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range - 32, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo - 32, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo - 33, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule + 137, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 29, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus + 138, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 138, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 138, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 137, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 27, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo + 24, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState + 23, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState + 22, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState + 21, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState + 25, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState + 26, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState + 61, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent + 28, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState + 35, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network + 133, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 134, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 36, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo + 36, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo + 37, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule 0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel 0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel - 43, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State - 52, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags - 54, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage - 2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity - 3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 115, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 113, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry - 57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 114, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 72, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile - 115, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 47, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State + 56, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags + 58, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage + 6, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity + 7, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category + 138, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 135, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 61, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent + 137, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 76, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 138, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp 1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol - 104, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady - 114, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration - 114, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration - 30, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList - 5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest - 7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest - 9, // 41: daemon.DaemonService.Up:input_type -> daemon.UpRequest - 11, // 42: daemon.DaemonService.Status:input_type -> daemon.StatusRequest - 11, // 43: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest - 13, // 44: daemon.DaemonService.Down:input_type -> daemon.DownRequest - 15, // 45: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest - 26, // 46: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest - 28, // 47: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest - 28, // 48: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest - 4, // 49: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest - 35, // 50: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest - 37, // 51: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest - 39, // 52: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest - 44, // 53: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest - 46, // 54: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest - 48, // 55: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest - 50, // 56: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest - 53, // 57: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 105, // 58: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest - 107, // 59: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest - 109, // 60: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest - 56, // 61: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest - 58, // 62: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest - 41, // 63: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest - 60, // 64: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest - 62, // 65: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest - 64, // 66: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest - 66, // 67: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest - 68, // 68: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest - 70, // 69: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest - 73, // 70: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest - 75, // 71: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest - 79, // 72: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 82, // 73: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest - 84, // 74: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest - 86, // 75: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest - 88, // 76: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest - 90, // 77: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest - 92, // 78: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest - 94, // 79: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest - 96, // 80: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest - 98, // 81: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest - 100, // 82: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest - 102, // 83: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest - 77, // 84: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest - 6, // 85: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 8, // 86: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 10, // 87: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 12, // 88: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 12, // 89: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse - 14, // 90: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 16, // 91: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 27, // 92: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 29, // 93: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 29, // 94: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 34, // 95: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 36, // 96: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 38, // 97: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 40, // 98: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 45, // 99: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 47, // 100: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 49, // 101: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 51, // 102: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 55, // 103: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 106, // 104: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket - 108, // 105: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse - 110, // 106: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse - 57, // 107: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 59, // 108: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 42, // 109: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse - 61, // 110: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 63, // 111: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 65, // 112: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 67, // 113: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse - 69, // 114: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 71, // 115: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 74, // 116: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 76, // 117: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 80, // 118: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 83, // 119: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse - 85, // 120: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 87, // 121: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 89, // 122: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 91, // 123: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse - 93, // 124: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse - 95, // 125: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse - 97, // 126: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 99, // 127: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 101, // 128: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 103, // 129: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent - 78, // 130: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse - 85, // [85:131] is the sub-list for method output_type - 39, // [39:85] is the sub-list for method input_type - 39, // [39:39] is the sub-list for extension type_name - 39, // [39:39] is the sub-list for extension extendee - 0, // [0:39] is the sub-list for field type_name + 108, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 137, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 137, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 115, // 38: daemon.FileDropTransfer.files:type_name -> daemon.FileDropFile + 5, // 39: daemon.FileDropTransfer.state:type_name -> daemon.FileDropState + 138, // 40: daemon.FileDropTransfer.createdAt:type_name -> google.protobuf.Timestamp + 138, // 41: daemon.FileDropTransfer.updatedAt:type_name -> google.protobuf.Timestamp + 4, // 42: daemon.FileDropTransfer.reason:type_name -> daemon.FileDropReason + 116, // 43: daemon.FileDropListTransfersResponse.transfers:type_name -> daemon.FileDropTransfer + 2, // 44: daemon.FileDropGetSettingsResponse.mode:type_name -> daemon.FileDropMode + 136, // 45: daemon.FileDropGetSettingsResponse.peerRules:type_name -> daemon.FileDropGetSettingsResponse.PeerRulesEntry + 2, // 46: daemon.FileDropSetSettingsRequest.mode:type_name -> daemon.FileDropMode + 3, // 47: daemon.FileDropSetPeerRuleRequest.rule:type_name -> daemon.FileDropRule + 34, // 48: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList + 3, // 49: daemon.FileDropGetSettingsResponse.PeerRulesEntry.value:type_name -> daemon.FileDropRule + 9, // 50: daemon.DaemonService.Login:input_type -> daemon.LoginRequest + 11, // 51: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest + 13, // 52: daemon.DaemonService.Up:input_type -> daemon.UpRequest + 15, // 53: daemon.DaemonService.Status:input_type -> daemon.StatusRequest + 15, // 54: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest + 17, // 55: daemon.DaemonService.Down:input_type -> daemon.DownRequest + 19, // 56: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest + 30, // 57: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest + 32, // 58: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest + 32, // 59: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest + 8, // 60: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest + 39, // 61: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest + 41, // 62: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest + 43, // 63: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest + 48, // 64: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest + 50, // 65: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest + 52, // 66: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest + 54, // 67: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest + 57, // 68: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest + 109, // 69: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest + 111, // 70: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest + 113, // 71: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest + 60, // 72: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest + 62, // 73: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest + 45, // 74: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest + 64, // 75: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest + 66, // 76: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest + 68, // 77: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest + 70, // 78: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest + 72, // 79: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest + 74, // 80: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 77, // 81: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 79, // 82: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 83, // 83: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 86, // 84: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 88, // 85: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 90, // 86: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 92, // 87: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 94, // 88: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest + 96, // 89: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest + 98, // 90: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest + 100, // 91: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 102, // 92: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 104, // 93: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 106, // 94: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 81, // 95: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest + 117, // 96: daemon.DaemonService.FileDropSend:input_type -> daemon.FileDropSendRequest + 119, // 97: daemon.DaemonService.FileDropDecide:input_type -> daemon.FileDropDecideRequest + 121, // 98: daemon.DaemonService.FileDropCancel:input_type -> daemon.FileDropCancelRequest + 123, // 99: daemon.DaemonService.FileDropListTransfers:input_type -> daemon.FileDropListTransfersRequest + 125, // 100: daemon.DaemonService.FileDropDeleteTransfer:input_type -> daemon.FileDropDeleteTransferRequest + 127, // 101: daemon.DaemonService.FileDropGetSettings:input_type -> daemon.FileDropGetSettingsRequest + 129, // 102: daemon.DaemonService.FileDropSetSettings:input_type -> daemon.FileDropSetSettingsRequest + 131, // 103: daemon.DaemonService.FileDropSetPeerRule:input_type -> daemon.FileDropSetPeerRuleRequest + 10, // 104: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 12, // 105: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 14, // 106: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 16, // 107: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 16, // 108: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse + 18, // 109: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 20, // 110: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 31, // 111: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 33, // 112: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 33, // 113: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 38, // 114: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 40, // 115: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 42, // 116: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 44, // 117: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 49, // 118: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 51, // 119: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 53, // 120: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 55, // 121: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 59, // 122: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 110, // 123: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 112, // 124: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 114, // 125: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 61, // 126: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 63, // 127: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 46, // 128: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse + 65, // 129: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 67, // 130: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 69, // 131: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 71, // 132: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse + 73, // 133: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 75, // 134: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 78, // 135: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 80, // 136: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 84, // 137: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 87, // 138: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 89, // 139: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 91, // 140: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 93, // 141: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 95, // 142: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse + 97, // 143: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse + 99, // 144: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse + 101, // 145: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 103, // 146: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 105, // 147: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 107, // 148: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 82, // 149: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse + 118, // 150: daemon.DaemonService.FileDropSend:output_type -> daemon.FileDropSendResponse + 120, // 151: daemon.DaemonService.FileDropDecide:output_type -> daemon.FileDropDecideResponse + 122, // 152: daemon.DaemonService.FileDropCancel:output_type -> daemon.FileDropCancelResponse + 124, // 153: daemon.DaemonService.FileDropListTransfers:output_type -> daemon.FileDropListTransfersResponse + 126, // 154: daemon.DaemonService.FileDropDeleteTransfer:output_type -> daemon.FileDropDeleteTransferResponse + 128, // 155: daemon.DaemonService.FileDropGetSettings:output_type -> daemon.FileDropGetSettingsResponse + 130, // 156: daemon.DaemonService.FileDropSetSettings:output_type -> daemon.FileDropSetSettingsResponse + 132, // 157: daemon.DaemonService.FileDropSetPeerRule:output_type -> daemon.FileDropSetPeerRuleResponse + 104, // [104:158] is the sub-list for method output_type + 50, // [50:104] is the sub-list for method input_type + 50, // [50:50] is the sub-list for extension type_name + 50, // [50:50] is the sub-list for extension extendee + 0, // [0:50] is the sub-list for field type_name } func init() { file_daemon_proto_init() } @@ -7983,8 +9257,8 @@ func file_daemon_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), - NumEnums: 4, - NumMessages: 110, + NumEnums: 8, + NumMessages: 129, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.pb.gw.go b/client/proto/daemon.pb.gw.go index b64dfeea1..e8e523c86 100644 --- a/client/proto/daemon.pb.gw.go +++ b/client/proto/daemon.pb.gw.go @@ -1123,6 +1123,198 @@ func local_request_DaemonService_WailsUIReady_0(ctx context.Context, marshaler r return msg, metadata, err } +func request_DaemonService_FileDropSend_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropSendRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.FileDropSend(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_FileDropSend_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropSendRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.FileDropSend(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_FileDropDecide_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropDecideRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.FileDropDecide(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_FileDropDecide_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropDecideRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.FileDropDecide(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_FileDropCancel_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropCancelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.FileDropCancel(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_FileDropCancel_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropCancelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.FileDropCancel(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_FileDropListTransfers_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropListTransfersRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.FileDropListTransfers(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_FileDropListTransfers_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropListTransfersRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.FileDropListTransfers(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_FileDropDeleteTransfer_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropDeleteTransferRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.FileDropDeleteTransfer(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_FileDropDeleteTransfer_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropDeleteTransferRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.FileDropDeleteTransfer(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_FileDropGetSettings_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropGetSettingsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.FileDropGetSettings(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_FileDropGetSettings_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropGetSettingsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.FileDropGetSettings(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_FileDropSetSettings_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropSetSettingsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.FileDropSetSettings(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_FileDropSetSettings_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropSetSettingsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.FileDropSetSettings(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_FileDropSetPeerRule_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropSetPeerRuleRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.FileDropSetPeerRule(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_FileDropSetPeerRule_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq FileDropSetPeerRuleRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.FileDropSetPeerRule(ctx, &protoReq) + return msg, metadata, err +} + // RegisterDaemonServiceHandlerServer registers the http handlers for service DaemonService to "mux". // UnaryRPC :call DaemonServiceServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. @@ -1997,6 +2189,166 @@ func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeM } forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSend_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropSend", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSend")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_FileDropSend_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropSend_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropDecide_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropDecide", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropDecide")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_FileDropDecide_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropDecide_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropCancel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropCancel", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropCancel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_FileDropCancel_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropCancel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropListTransfers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropListTransfers", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropListTransfers")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_FileDropListTransfers_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropListTransfers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropDeleteTransfer_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropDeleteTransfer", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropDeleteTransfer")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_FileDropDeleteTransfer_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropDeleteTransfer_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropGetSettings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropGetSettings", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropGetSettings")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_FileDropGetSettings_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropGetSettings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSetSettings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropSetSettings", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSetSettings")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_FileDropSetSettings_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropSetSettings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSetPeerRule_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropSetPeerRule", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSetPeerRule")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_FileDropSetPeerRule_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropSetPeerRule_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -2819,6 +3171,142 @@ func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeM } forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSend_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropSend", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSend")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_FileDropSend_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropSend_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropDecide_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropDecide", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropDecide")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_FileDropDecide_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropDecide_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropCancel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropCancel", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropCancel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_FileDropCancel_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropCancel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropListTransfers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropListTransfers", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropListTransfers")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_FileDropListTransfers_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropListTransfers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropDeleteTransfer_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropDeleteTransfer", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropDeleteTransfer")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_FileDropDeleteTransfer_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropDeleteTransfer_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropGetSettings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropGetSettings", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropGetSettings")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_FileDropGetSettings_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropGetSettings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSetSettings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropSetSettings", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSetSettings")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_FileDropSetSettings_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropSetSettings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSetPeerRule_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropSetPeerRule", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSetPeerRule")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_FileDropSetPeerRule_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_FileDropSetPeerRule_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -2869,6 +3357,14 @@ var ( pattern_DaemonService_GetInstallerResult_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetInstallerResult"}, "")) pattern_DaemonService_ExposeService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ExposeService"}, "")) pattern_DaemonService_WailsUIReady_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WailsUIReady"}, "")) + pattern_DaemonService_FileDropSend_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropSend"}, "")) + pattern_DaemonService_FileDropDecide_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropDecide"}, "")) + pattern_DaemonService_FileDropCancel_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropCancel"}, "")) + pattern_DaemonService_FileDropListTransfers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropListTransfers"}, "")) + pattern_DaemonService_FileDropDeleteTransfer_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropDeleteTransfer"}, "")) + pattern_DaemonService_FileDropGetSettings_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropGetSettings"}, "")) + pattern_DaemonService_FileDropSetSettings_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropSetSettings"}, "")) + pattern_DaemonService_FileDropSetPeerRule_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropSetPeerRule"}, "")) ) var ( @@ -2918,4 +3414,12 @@ var ( forward_DaemonService_GetInstallerResult_0 = runtime.ForwardResponseMessage forward_DaemonService_ExposeService_0 = runtime.ForwardResponseStream forward_DaemonService_WailsUIReady_0 = runtime.ForwardResponseMessage + forward_DaemonService_FileDropSend_0 = runtime.ForwardResponseMessage + forward_DaemonService_FileDropDecide_0 = runtime.ForwardResponseMessage + forward_DaemonService_FileDropCancel_0 = runtime.ForwardResponseMessage + forward_DaemonService_FileDropListTransfers_0 = runtime.ForwardResponseMessage + forward_DaemonService_FileDropDeleteTransfer_0 = runtime.ForwardResponseMessage + forward_DaemonService_FileDropGetSettings_0 = runtime.ForwardResponseMessage + forward_DaemonService_FileDropSetSettings_0 = runtime.ForwardResponseMessage + forward_DaemonService_FileDropSetPeerRule_0 = runtime.ForwardResponseMessage ) diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index a3e3f4500..59284d858 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -156,6 +156,32 @@ service DaemonService { // only cares whether the daemon implements it: an Unimplemented response // means the daemon predates this UI and is too old to drive it. rpc WailsUIReady(WailsUIReadyRequest) returns (WailsUIReadyResponse) {} + + // FileDropSend starts an asynchronous file transfer to a peer. Progress is + // polled via FileDropListTransfers; the outcome also arrives as a SystemEvent. + rpc FileDropSend(FileDropSendRequest) returns (FileDropSendResponse) {} + + // FileDropDecide accepts or declines a pending incoming offer. + rpc FileDropDecide(FileDropDecideRequest) returns (FileDropDecideResponse) {} + + // FileDropCancel aborts a transfer in either direction. + rpc FileDropCancel(FileDropCancelRequest) returns (FileDropCancelResponse) {} + + // FileDropListTransfers returns the transfer history, newest first, with live + // progress for running transfers. + rpc FileDropListTransfers(FileDropListTransfersRequest) returns (FileDropListTransfersResponse) {} + + // FileDropDeleteTransfer removes one entry from the transfer history. + rpc FileDropDeleteTransfer(FileDropDeleteTransferRequest) returns (FileDropDeleteTransferResponse) {} + + // FileDropGetSettings returns the active profile's receiving policy. + rpc FileDropGetSettings(FileDropGetSettingsRequest) returns (FileDropGetSettingsResponse) {} + + // FileDropSetSettings updates the active profile's receiving policy. + rpc FileDropSetSettings(FileDropSetSettingsRequest) returns (FileDropSetSettingsResponse) {} + + // FileDropSetPeerRule sets or clears a per-sender exception. + rpc FileDropSetPeerRule(FileDropSetPeerRuleRequest) returns (FileDropSetPeerRuleResponse) {} } @@ -1063,3 +1089,119 @@ message StartBundleCaptureRequest { message StartBundleCaptureResponse {} message StopBundleCaptureRequest {} message StopBundleCaptureResponse {} + +// FileDropMode is the device-local policy for incoming file offers. +enum FileDropMode { + FILE_DROP_MODE_OFF = 0; + FILE_DROP_MODE_ASK = 1; + FILE_DROP_MODE_AUTO = 2; +} + +// FileDropRule is a per-sender exception on top of the base mode. +enum FileDropRule { + FILE_DROP_RULE_DEFAULT = 0; + FILE_DROP_RULE_ALWAYS = 1; + FILE_DROP_RULE_BLOCK = 2; +} + +// FileDropReason classifies why a transfer failed, when it is known. +enum FileDropReason { + FILE_DROP_REASON_NONE = 0; + FILE_DROP_REASON_UNREACHABLE = 1; +} + +// FileDropState is the lifecycle state of a transfer. +enum FileDropState { + FILE_DROP_STATE_PENDING = 0; + FILE_DROP_STATE_TRANSFERRING = 1; + FILE_DROP_STATE_COMPLETED = 2; + FILE_DROP_STATE_DECLINED = 3; + FILE_DROP_STATE_EXPIRED = 4; + FILE_DROP_STATE_CANCELLED = 5; + FILE_DROP_STATE_FAILED = 6; +} + +message FileDropFile { + string name = 1; + int64 size = 2; + string contentType = 3; + bool isText = 4; + // text carries an inline snippet; it never becomes a file on disk. + string text = 5; +} + +message FileDropTransfer { + string id = 1; + bool outgoing = 2; + string peerKey = 3; + string peerName = 4; + repeated FileDropFile files = 5; + FileDropState state = 6; + int64 transferred = 7; + int64 totalSize = 8; + google.protobuf.Timestamp createdAt = 9; + google.protobuf.Timestamp updatedAt = 10; + repeated string deliveredPaths = 11; + string error = 12; + FileDropReason reason = 13; +} + +message FileDropSendRequest { + string peerKey = 1; + // paths are local files to send; the daemon opens them as the caller. + repeated string paths = 2; + // text is an inline snippet payload, sent instead of or alongside files. + string text = 3; +} + +message FileDropSendResponse { + string transferId = 1; +} + +message FileDropDecideRequest { + string transferId = 1; + bool accept = 2; +} + +message FileDropDecideResponse {} + +message FileDropCancelRequest { + string transferId = 1; +} + +message FileDropCancelResponse {} + +message FileDropListTransfersRequest {} + +message FileDropListTransfersResponse { + repeated FileDropTransfer transfers = 1; +} + +message FileDropDeleteTransferRequest { + string transferId = 1; +} + +message FileDropDeleteTransferResponse {} + +message FileDropGetSettingsRequest {} + +message FileDropGetSettingsResponse { + FileDropMode mode = 1; + string destinationDir = 2; + // peerRules is keyed by the peer's public key. + map peerRules = 3; +} + +message FileDropSetSettingsRequest { + FileDropMode mode = 1; + string destinationDir = 2; +} + +message FileDropSetSettingsResponse {} + +message FileDropSetPeerRuleRequest { + string peerKey = 1; + FileDropRule rule = 2; +} + +message FileDropSetPeerRuleResponse {} diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 2d01d474d..5bec343fa 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v6.33.1 +// - protoc v7.34.1 // source: daemon.proto package proto @@ -65,6 +65,14 @@ const ( DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult" DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService" DaemonService_WailsUIReady_FullMethodName = "/daemon.DaemonService/WailsUIReady" + DaemonService_FileDropSend_FullMethodName = "/daemon.DaemonService/FileDropSend" + DaemonService_FileDropDecide_FullMethodName = "/daemon.DaemonService/FileDropDecide" + DaemonService_FileDropCancel_FullMethodName = "/daemon.DaemonService/FileDropCancel" + DaemonService_FileDropListTransfers_FullMethodName = "/daemon.DaemonService/FileDropListTransfers" + DaemonService_FileDropDeleteTransfer_FullMethodName = "/daemon.DaemonService/FileDropDeleteTransfer" + DaemonService_FileDropGetSettings_FullMethodName = "/daemon.DaemonService/FileDropGetSettings" + DaemonService_FileDropSetSettings_FullMethodName = "/daemon.DaemonService/FileDropSetSettings" + DaemonService_FileDropSetPeerRule_FullMethodName = "/daemon.DaemonService/FileDropSetPeerRule" ) // DaemonServiceClient is the client API for DaemonService service. @@ -171,6 +179,24 @@ type DaemonServiceClient interface { // only cares whether the daemon implements it: an Unimplemented response // means the daemon predates this UI and is too old to drive it. WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error) + // FileDropSend starts an asynchronous file transfer to a peer. Progress is + // polled via FileDropListTransfers; the outcome also arrives as a SystemEvent. + FileDropSend(ctx context.Context, in *FileDropSendRequest, opts ...grpc.CallOption) (*FileDropSendResponse, error) + // FileDropDecide accepts or declines a pending incoming offer. + FileDropDecide(ctx context.Context, in *FileDropDecideRequest, opts ...grpc.CallOption) (*FileDropDecideResponse, error) + // FileDropCancel aborts a transfer in either direction. + FileDropCancel(ctx context.Context, in *FileDropCancelRequest, opts ...grpc.CallOption) (*FileDropCancelResponse, error) + // FileDropListTransfers returns the transfer history, newest first, with live + // progress for running transfers. + FileDropListTransfers(ctx context.Context, in *FileDropListTransfersRequest, opts ...grpc.CallOption) (*FileDropListTransfersResponse, error) + // FileDropDeleteTransfer removes one entry from the transfer history. + FileDropDeleteTransfer(ctx context.Context, in *FileDropDeleteTransferRequest, opts ...grpc.CallOption) (*FileDropDeleteTransferResponse, error) + // FileDropGetSettings returns the active profile's receiving policy. + FileDropGetSettings(ctx context.Context, in *FileDropGetSettingsRequest, opts ...grpc.CallOption) (*FileDropGetSettingsResponse, error) + // FileDropSetSettings updates the active profile's receiving policy. + FileDropSetSettings(ctx context.Context, in *FileDropSetSettingsRequest, opts ...grpc.CallOption) (*FileDropSetSettingsResponse, error) + // FileDropSetPeerRule sets or clears a per-sender exception. + FileDropSetPeerRule(ctx context.Context, in *FileDropSetPeerRuleRequest, opts ...grpc.CallOption) (*FileDropSetPeerRuleResponse, error) } type daemonServiceClient struct { @@ -677,6 +703,86 @@ func (c *daemonServiceClient) WailsUIReady(ctx context.Context, in *WailsUIReady return out, nil } +func (c *daemonServiceClient) FileDropSend(ctx context.Context, in *FileDropSendRequest, opts ...grpc.CallOption) (*FileDropSendResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FileDropSendResponse) + err := c.cc.Invoke(ctx, DaemonService_FileDropSend_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) FileDropDecide(ctx context.Context, in *FileDropDecideRequest, opts ...grpc.CallOption) (*FileDropDecideResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FileDropDecideResponse) + err := c.cc.Invoke(ctx, DaemonService_FileDropDecide_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) FileDropCancel(ctx context.Context, in *FileDropCancelRequest, opts ...grpc.CallOption) (*FileDropCancelResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FileDropCancelResponse) + err := c.cc.Invoke(ctx, DaemonService_FileDropCancel_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) FileDropListTransfers(ctx context.Context, in *FileDropListTransfersRequest, opts ...grpc.CallOption) (*FileDropListTransfersResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FileDropListTransfersResponse) + err := c.cc.Invoke(ctx, DaemonService_FileDropListTransfers_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) FileDropDeleteTransfer(ctx context.Context, in *FileDropDeleteTransferRequest, opts ...grpc.CallOption) (*FileDropDeleteTransferResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FileDropDeleteTransferResponse) + err := c.cc.Invoke(ctx, DaemonService_FileDropDeleteTransfer_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) FileDropGetSettings(ctx context.Context, in *FileDropGetSettingsRequest, opts ...grpc.CallOption) (*FileDropGetSettingsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FileDropGetSettingsResponse) + err := c.cc.Invoke(ctx, DaemonService_FileDropGetSettings_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) FileDropSetSettings(ctx context.Context, in *FileDropSetSettingsRequest, opts ...grpc.CallOption) (*FileDropSetSettingsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FileDropSetSettingsResponse) + err := c.cc.Invoke(ctx, DaemonService_FileDropSetSettings_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) FileDropSetPeerRule(ctx context.Context, in *FileDropSetPeerRuleRequest, opts ...grpc.CallOption) (*FileDropSetPeerRuleResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FileDropSetPeerRuleResponse) + err := c.cc.Invoke(ctx, DaemonService_FileDropSetPeerRule_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // DaemonServiceServer is the server API for DaemonService service. // All implementations must embed UnimplementedDaemonServiceServer // for forward compatibility. @@ -781,6 +887,24 @@ type DaemonServiceServer interface { // only cares whether the daemon implements it: an Unimplemented response // means the daemon predates this UI and is too old to drive it. WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) + // FileDropSend starts an asynchronous file transfer to a peer. Progress is + // polled via FileDropListTransfers; the outcome also arrives as a SystemEvent. + FileDropSend(context.Context, *FileDropSendRequest) (*FileDropSendResponse, error) + // FileDropDecide accepts or declines a pending incoming offer. + FileDropDecide(context.Context, *FileDropDecideRequest) (*FileDropDecideResponse, error) + // FileDropCancel aborts a transfer in either direction. + FileDropCancel(context.Context, *FileDropCancelRequest) (*FileDropCancelResponse, error) + // FileDropListTransfers returns the transfer history, newest first, with live + // progress for running transfers. + FileDropListTransfers(context.Context, *FileDropListTransfersRequest) (*FileDropListTransfersResponse, error) + // FileDropDeleteTransfer removes one entry from the transfer history. + FileDropDeleteTransfer(context.Context, *FileDropDeleteTransferRequest) (*FileDropDeleteTransferResponse, error) + // FileDropGetSettings returns the active profile's receiving policy. + FileDropGetSettings(context.Context, *FileDropGetSettingsRequest) (*FileDropGetSettingsResponse, error) + // FileDropSetSettings updates the active profile's receiving policy. + FileDropSetSettings(context.Context, *FileDropSetSettingsRequest) (*FileDropSetSettingsResponse, error) + // FileDropSetPeerRule sets or clears a per-sender exception. + FileDropSetPeerRule(context.Context, *FileDropSetPeerRuleRequest) (*FileDropSetPeerRuleResponse, error) mustEmbedUnimplementedDaemonServiceServer() } @@ -929,6 +1053,30 @@ func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grp func (UnimplementedDaemonServiceServer) WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) { return nil, status.Error(codes.Unimplemented, "method WailsUIReady not implemented") } +func (UnimplementedDaemonServiceServer) FileDropSend(context.Context, *FileDropSendRequest) (*FileDropSendResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FileDropSend not implemented") +} +func (UnimplementedDaemonServiceServer) FileDropDecide(context.Context, *FileDropDecideRequest) (*FileDropDecideResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FileDropDecide not implemented") +} +func (UnimplementedDaemonServiceServer) FileDropCancel(context.Context, *FileDropCancelRequest) (*FileDropCancelResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FileDropCancel not implemented") +} +func (UnimplementedDaemonServiceServer) FileDropListTransfers(context.Context, *FileDropListTransfersRequest) (*FileDropListTransfersResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FileDropListTransfers not implemented") +} +func (UnimplementedDaemonServiceServer) FileDropDeleteTransfer(context.Context, *FileDropDeleteTransferRequest) (*FileDropDeleteTransferResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FileDropDeleteTransfer not implemented") +} +func (UnimplementedDaemonServiceServer) FileDropGetSettings(context.Context, *FileDropGetSettingsRequest) (*FileDropGetSettingsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FileDropGetSettings not implemented") +} +func (UnimplementedDaemonServiceServer) FileDropSetSettings(context.Context, *FileDropSetSettingsRequest) (*FileDropSetSettingsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FileDropSetSettings not implemented") +} +func (UnimplementedDaemonServiceServer) FileDropSetPeerRule(context.Context, *FileDropSetPeerRuleRequest) (*FileDropSetPeerRuleResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FileDropSetPeerRule not implemented") +} func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {} func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {} @@ -1750,6 +1898,150 @@ func _DaemonService_WailsUIReady_Handler(srv interface{}, ctx context.Context, d return interceptor(ctx, in, info, handler) } +func _DaemonService_FileDropSend_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FileDropSendRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).FileDropSend(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_FileDropSend_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).FileDropSend(ctx, req.(*FileDropSendRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_FileDropDecide_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FileDropDecideRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).FileDropDecide(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_FileDropDecide_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).FileDropDecide(ctx, req.(*FileDropDecideRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_FileDropCancel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FileDropCancelRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).FileDropCancel(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_FileDropCancel_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).FileDropCancel(ctx, req.(*FileDropCancelRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_FileDropListTransfers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FileDropListTransfersRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).FileDropListTransfers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_FileDropListTransfers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).FileDropListTransfers(ctx, req.(*FileDropListTransfersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_FileDropDeleteTransfer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FileDropDeleteTransferRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).FileDropDeleteTransfer(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_FileDropDeleteTransfer_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).FileDropDeleteTransfer(ctx, req.(*FileDropDeleteTransferRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_FileDropGetSettings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FileDropGetSettingsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).FileDropGetSettings(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_FileDropGetSettings_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).FileDropGetSettings(ctx, req.(*FileDropGetSettingsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_FileDropSetSettings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FileDropSetSettingsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).FileDropSetSettings(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_FileDropSetSettings_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).FileDropSetSettings(ctx, req.(*FileDropSetSettingsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_FileDropSetPeerRule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FileDropSetPeerRuleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).FileDropSetPeerRule(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_FileDropSetPeerRule_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).FileDropSetPeerRule(ctx, req.(*FileDropSetPeerRuleRequest)) + } + return interceptor(ctx, in, info, handler) +} + // DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -1925,6 +2217,38 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "WailsUIReady", Handler: _DaemonService_WailsUIReady_Handler, }, + { + MethodName: "FileDropSend", + Handler: _DaemonService_FileDropSend_Handler, + }, + { + MethodName: "FileDropDecide", + Handler: _DaemonService_FileDropDecide_Handler, + }, + { + MethodName: "FileDropCancel", + Handler: _DaemonService_FileDropCancel_Handler, + }, + { + MethodName: "FileDropListTransfers", + Handler: _DaemonService_FileDropListTransfers_Handler, + }, + { + MethodName: "FileDropDeleteTransfer", + Handler: _DaemonService_FileDropDeleteTransfer_Handler, + }, + { + MethodName: "FileDropGetSettings", + Handler: _DaemonService_FileDropGetSettings_Handler, + }, + { + MethodName: "FileDropSetSettings", + Handler: _DaemonService_FileDropSetSettings_Handler, + }, + { + MethodName: "FileDropSetPeerRule", + Handler: _DaemonService_FileDropSetPeerRule_Handler, + }, }, Streams: []grpc.StreamDesc{ { diff --git a/client/proto/metadata.go b/client/proto/metadata.go index 9b1dbd16e..93c88b45d 100644 --- a/client/proto/metadata.go +++ b/client/proto/metadata.go @@ -59,3 +59,24 @@ const ( // MetadataSourceMDM marks a config_changed driven by an MDM policy diff. MetadataSourceMDM = "mdm" ) + +// SystemEvent metadata markers for file drop transfers. The daemon publishes one +// per transfer milestone; the UI shows a notification and refreshes its transfer +// views. Progress is not evented — the UI polls FileDropListTransfers. +const ( + // MetadataKindFileDropOffer marks an incoming offer awaiting the user's decision. + MetadataKindFileDropOffer = "filedrop-offer" + // MetadataKindFileDropCompleted marks a finished transfer in either direction. + MetadataKindFileDropCompleted = "filedrop-completed" + // MetadataKindFileDropFailed marks a transfer that ended without completing. + MetadataKindFileDropFailed = "filedrop-failed" + // MetadataKindFileDropWithdrawn marks a pending offer that was cancelled by the + // sender or expired, so its consent prompt should be dismissed. + MetadataKindFileDropWithdrawn = "filedrop-withdrawn" + + // MetadataFileDropTransferKey carries the transfer ID for the filedrop-* kinds. + MetadataFileDropTransferKey = "filedropTransferId" + // MetadataFileDropPeerKey carries the remote peer's public key for the + // filedrop-* kinds, so notification actions can set per-sender rules. + MetadataFileDropPeerKey = "filedropPeerKey" +) diff --git a/client/server/filedrop.go b/client/server/filedrop.go new file mode 100644 index 000000000..5e1cd2df1 --- /dev/null +++ b/client/server/filedrop.go @@ -0,0 +1,412 @@ +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) + } +} diff --git a/client/server/filedrop_unix.go b/client/server/filedrop_unix.go new file mode 100644 index 000000000..27b88fcb8 --- /dev/null +++ b/client/server/filedrop_unix.go @@ -0,0 +1,25 @@ +//go:build !windows + +package server + +import ( + "errors" + "os" + "syscall" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +func checkDirOwnership(caller ipcauth.Identity, _ string, info os.FileInfo) error { + if caller.UID == 0 { + return nil + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("cannot determine destination ownership") + } + if stat.Uid != caller.UID { + return errors.New("destination is not owned by the caller") + } + return nil +} diff --git a/client/server/filedrop_windows.go b/client/server/filedrop_windows.go new file mode 100644 index 000000000..e7549fbc3 --- /dev/null +++ b/client/server/filedrop_windows.go @@ -0,0 +1,11 @@ +package server + +import ( + "os" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +func checkDirOwnership(ipcauth.Identity, string, os.FileInfo) error { + return nil +} diff --git a/client/server/server.go b/client/server/server.go index f33e19075..7b7260d78 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -31,6 +31,7 @@ import ( "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/filedrop" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/updater" @@ -109,6 +110,8 @@ type Server struct { statusRecorder *peer.Status sessionWatcher *internal.SessionWatcher + fileDrop *filedrop.Manager + probeThrottle *probeThrottle persistSyncResponse bool isSessionActive atomic.Bool @@ -2351,6 +2354,12 @@ func (s *Server) connect(ctx context.Context, config *profilemanager.Config, sta client.SetUpdateManager(s.updateManager) client.SetSyncResponsePersistence(s.persistSyncResponse) + if mgr, err := s.fileDropManager(); err != nil { + log.Warnf("file drop is unavailable: %v", err) + } else { + client.SetFileDropManager(mgr) + } + s.mutex.Lock() s.connectClient = client s.mutex.Unlock() diff --git a/client/ui/frontend/src/contexts/NavSectionContext.tsx b/client/ui/frontend/src/contexts/NavSectionContext.tsx index c08a3c824..02ee6a221 100644 --- a/client/ui/frontend/src/contexts/NavSectionContext.tsx +++ b/client/ui/frontend/src/contexts/NavSectionContext.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; -export type NavSection = "peers" | "networks"; +export type NavSection = "peers" | "networks" | "files"; type NavSectionContextValue = { section: NavSection; diff --git a/client/ui/frontend/src/lib/filedrop.ts b/client/ui/frontend/src/lib/filedrop.ts new file mode 100644 index 000000000..39d53de47 --- /dev/null +++ b/client/ui/frontend/src/lib/filedrop.ts @@ -0,0 +1,47 @@ +export const enum TransferState { + Pending = 0, + Transferring = 1, + Completed = 2, + Declined = 3, + Expired = 4, + Cancelled = 5, + Failed = 6, +} + +export const enum ReceiveMode { + Off = 0, + Ask = 1, + Auto = 2, +} + +export const enum PeerRule { + Default = 0, + Always = 1, + Block = 2, +} + +export const enum FailureReason { + None = 0, + Unreachable = 1, +} + +export const isTerminalState = (state: number): boolean => state >= TransferState.Completed; + +export const stateLabelKey = (state: number): string => { + switch (state) { + case TransferState.Pending: + return "files.state.pending"; + case TransferState.Transferring: + return "files.state.transferring"; + case TransferState.Completed: + return "files.state.received"; + case TransferState.Declined: + return "files.state.declined"; + case TransferState.Expired: + return "files.state.expired"; + case TransferState.Cancelled: + return "files.state.cancelled"; + default: + return "files.state.failed"; + } +}; diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx index c05b3a025..61fc4922e 100644 --- a/client/ui/frontend/src/modules/main/MainPage.tsx +++ b/client/ui/frontend/src/modules/main/MainPage.tsx @@ -11,6 +11,7 @@ import { NotConnectedState } from "@/components/empty-state/NotConnectedState"; import { useStatus } from "@/contexts/StatusContext"; import { Peers } from "@/modules/main/advanced/peers/Peers"; import { Networks } from "@/modules/main/advanced/networks/Networks"; +import { Files } from "@/modules/main/advanced/files/Files"; import { NetworksProvider } from "@/contexts/NetworksContext"; import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext"; import { useRestrictions } from "@/contexts/RestrictionsContext"; @@ -44,7 +45,10 @@ const MainBody = () => { const isAdvanced = viewMode === "advanced"; return ( -
+ // min-w-0 matters here: without it this flex parent keeps its automatic + // minimum width, and a long file name in the right panel pushes the + // layout wider than the window, which cannot be resized. +
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS. See https://github.com/wailsapp/wails/issues/3260 */}
{ role={"tabpanel"} id={`nb-tabpanel-${section}`} aria-labelledby={`nb-tab-${section}`} - className={"flex min-h-0 flex-1 flex-col"} + // min-w-0: this is the section's direct parent, so without + // it a long file name sets the floor for the whole panel + // and the row overflows instead of truncating. + className={"flex min-h-0 min-w-0 flex-1 flex-col"} > {section === "peers" && } {section === "networks" && } + {section === "files" && }
{!isConnected && ( diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx index 8d69f3580..510417e4c 100644 --- a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -1,6 +1,6 @@ import { type ComponentType, type KeyboardEvent, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react"; +import { FolderDownIcon, Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react"; import { cn } from "@/lib/cn"; import { useNavSection, type NavSection } from "@/contexts/NavSectionContext"; import { useStatus } from "@/contexts/StatusContext"; @@ -40,6 +40,11 @@ export const Navigation = () => { icon: Layers3Icon, }); } + tabs.push({ + value: "files", + label: t("nav.files.title"), + icon: FolderDownIcon, + }); const tabRefs = useRef>({}); @@ -103,7 +108,7 @@ export const Navigation = () => { onKeyDown={handleKeyDown} disabled={isDisabled} className={cn( - "group relative flex flex-1 items-center justify-center", + "group relative flex min-w-0 flex-1 items-center justify-center", "gap-2.5 px-5 py-3.5", "outline-none transition-all", isFirst && "rounded-tl-xl", @@ -113,8 +118,8 @@ export const Navigation = () => { isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default", )} > - - {tab.label} + + {tab.label} { + const files = transfer.files ?? []; + if (files.length === 1 && files[0].isText) { + return `“${files[0].text}”`; + } + return files.map((f) => f.name).join(", "); +}; + +const isTextTransfer = (transfer: FileDropTransfer): boolean => + (transfer.files ?? []).length === 1 && transfer.files[0].isText; + +const timeLabel = (iso: string): string => { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +}; + +// Colours only what the eye should catch scanning the outcome column: a refusal +// or failure in red, a completed send in green. Everything else, a received file +// included, stays neutral so the exceptions stand out. +const outcomeClass = (transfer: FileDropTransfer, inProgress: boolean): string => { + if (inProgress) return "text-nb-gray-500"; + switch (transfer.state) { + case TransferState.Declined: + case TransferState.Failed: + return "text-red-400"; + case TransferState.Completed: + return transfer.outgoing ? "text-green-400" : "text-nb-gray-500"; + default: + return "text-nb-gray-500"; + } +}; + +export const Files = () => { + const { t } = useTranslation(); + const [transfers, setTransfers] = useState(null); + const [search, setSearch] = useState(""); + const searchRef = useRef(null); + + const refresh = useCallback(async () => { + try { + setTransfers(await FileDrop.List()); + } catch { + setTransfers((prev) => prev ?? []); + } + }, []); + + useEffect(() => { + searchRef.current?.focus(); + void refresh(); + const id = setInterval(() => void refresh(), POLL_MS); + const off = Events.On(EVENT_FILEDROP, () => void refresh()); + return () => { + clearInterval(id); + off(); + }; + }, [refresh]); + + const filtered = useMemo(() => { + const all = transfers ?? []; + const q = search.trim().toLowerCase(); + if (!q) return all; + return all.filter( + (tr) => + transferTitle(tr).toLowerCase().includes(q) || + tr.peerName.toLowerCase().includes(q), + ); + }, [transfers, search]); + + const pending = filtered.filter((tr) => !tr.outgoing && tr.state === TransferState.Pending); + const rest = filtered.filter((tr) => tr.outgoing || tr.state !== TransferState.Pending); + + const groups = useMemo(() => { + const byDay = new Map(); + for (const tr of rest) { + const d = new Date(tr.createdAt as unknown as string); + const key = Number.isNaN(d.getTime()) ? "" : d.toDateString(); + const list = byDay.get(key) ?? []; + list.push(tr); + byDay.set(key, list); + } + const today = new Date().toDateString(); + const yesterday = new Date(Date.now() - 86400000).toDateString(); + return Array.from(byDay.entries()).map(([key, list]) => { + let label = key; + if (key === today) label = t("files.group.today"); + else if (key === yesterday) label = t("files.group.yesterday"); + else if (key) label = new Date(key).toLocaleDateString(); + return { label, list }; + }); + }, [rest, t]); + + if (transfers !== null && transfers.length === 0) { + return ( + + ); + } + + return ( +
+
+
+ setSearch(e.target.value)} + /> +
+
+ {filtered.length === 0 ? ( + + ) : ( + + +
+ {pending.map((tr) => ( + + ))} + {groups.map((group) => ( +
+
+ {group.label} +
+ {group.list.map((tr) => ( + + ))} +
+ ))} +
+
+ + + +
+ )} +
+ ); +}; + +type RowProps = { + transfer: FileDropTransfer; + onChanged: () => void; +}; + +const PendingOfferRow = ({ transfer, onChanged }: RowProps) => { + const { t } = useTranslation(); + const [busy, setBusy] = useState(false); + + const decide = async (accept: boolean) => { + if (busy) return; + setBusy(true); + try { + await FileDrop.Decide(transfer.id, accept); + } finally { + setBusy(false); + onChanged(); + } + }; + + return ( + // The panel is a fixed ~500px wide, so the buttons sit under the text + // rather than beside it: side by side they would squeeze the file name + // down to a few characters. +
+
+ +
+ + {transferTitle(transfer)} + + {" · "} + {formatBytes(transfer.totalSize)} + + + + {t("files.offer.subtitle", { peer: transfer.peerName })} + +
+
+
+ + +
+
+ ); +}; + +const TransferRow = ({ transfer, onChanged }: RowProps) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const isText = isTextTransfer(transfer); + const live = !isTerminalState(transfer.state); + const delivered = + !transfer.outgoing && + transfer.state === TransferState.Completed && + (transfer.deliveredPaths ?? []).length > 0; + const progress = + transfer.state === TransferState.Transferring && transfer.totalSize > 0 + ? Math.min(100, Math.floor((transfer.transferred / transfer.totalSize) * 100)) + : null; + + // The subtitle carries who and how big; the outcome sits on the right next + // to the time, so a glance down that column reads the results. + const subtitleParts = [ + transfer.outgoing + ? t("files.row.to", { peer: transfer.peerName }) + : t("files.row.from", { peer: transfer.peerName }), + ]; + if (!isText) subtitleParts.push(formatBytes(transfer.totalSize)); + + let stateLabel: string; + if (progress !== null) { + stateLabel = t("files.state.progress", { percent: progress }); + } else if (transfer.state === TransferState.Completed) { + stateLabel = t(transfer.outgoing ? "files.state.sent" : "files.state.received"); + } else if ( + transfer.state === TransferState.Failed && + transfer.reason === FailureReason.Unreachable + ) { + stateLabel = t("files.state.unreachable"); + } else { + stateLabel = t(stateLabelKey(transfer.state)); + } + + const stateClass = outcomeClass(transfer, progress !== null); + const time = timeLabel(transfer.createdAt as unknown as string); + + const copyText = async () => { + await navigator.clipboard.writeText(transfer.files[0]?.text ?? ""); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+ {transfer.outgoing ? ( + + ) : ( + + )} +
+ + {transferTitle(transfer)} + + + {subtitleParts.join(" · ")} + +
+ {/* Time over outcome, capped: the window is a fixed 900px and some + translations of the state labels are long enough to eat the file + name if they share one line with the timestamp. */} + + {time} + {stateLabel} + + {/* The window is a fixed 900px wide and cannot be resized, so the + row actions overlay the outcome column on hover instead of + reserving width that the file name would otherwise lose. */} +
+ {isText && ( + void copyText()}> + {copied ? ( + + ) : ( + + )} + + )} + {delivered && ( + void FileDrop.Reveal(transfer.deliveredPaths[0])} + > + + + )} + {live && ( + { + await FileDrop.Cancel(transfer.id); + onChanged(); + }} + > + + + )} + +
+
+ ); +}; + +const RowIconButton = ({ + label, + onClick, + children, +}: { + label: string; + onClick: () => void; + children: React.ReactNode; +}) => ( + + + +); + +const TransferMenu = ({ transfer, delivered, onChanged }: RowProps & { delivered: boolean }) => { + const { t } = useTranslation(); + + const setRule = async (rule: PeerRule) => { + await FileDrop.SetPeerRule(transfer.peerKey, rule); + onChanged(); + }; + + return ( + + + + + + {delivered && ( + void FileDrop.Open(transfer.deliveredPaths[0])} + > + + {t("files.action.open")} + + )} + {!transfer.outgoing && ( + <> + void setRule(PeerRule.Always)}> + + {t("files.action.alwaysAccept")} + + void setRule(PeerRule.Block)}> + + {t("files.action.block")} + + + )} + { + await FileDrop.Delete(transfer.id); + onChanged(); + }} + > + + {t("files.action.delete")} + + + + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx index 16500589d..71ccbf278 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -20,6 +20,7 @@ import { Check as CheckIcon, ChevronDownIcon, ChevronsLeftRightEllipsisIcon, + ClipboardIcon, ClockIcon, Copy as CopyIcon, GaugeIcon, @@ -31,9 +32,13 @@ import { MonitorIcon, Radio, RefreshCwIcon, + SendIcon, WaypointsIcon, } from "lucide-react"; +import { FileDrop } from "@bindings/services"; import type { PeerStatus } from "@bindings/services/models.js"; +import { Button } from "@/components/buttons/Button"; +import { useNavSection } from "@/contexts/NavSectionContext"; import { cn } from "@/lib/cn"; import { CopyToClipboard } from "@/components/CopyToClipboard"; import { Tooltip } from "@/components/Tooltip"; @@ -250,6 +255,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { + { ); }; +const PeerSendActions = ({ peer }: { peer: PeerStatus }) => { + const { t } = useTranslation(); + const { setSection } = useNavSection(); + const { setSelected } = usePeerDetail(); + const [error, setError] = useState(null); + // Deliberately not gated on connStatus: an idle peer is the normal resting + // state under lazy connections, and the outgoing packets of the transfer are + // exactly what wakes it. Only a peer without an overlay address has nothing + // to dial. + const canSend = peer.ip !== ""; + + const finishSend = () => { + setSelected(null); + setSection("files"); + }; + + const sendFiles = async () => { + setError(null); + try { + const paths = await FileDrop.PickFiles(); + if (!paths || paths.length === 0) return; + await FileDrop.Send(peer.pubKey, paths, ""); + finishSend(); + } catch (e) { + setError(String(e)); + } + }; + + const sendClipboard = async () => { + setError(null); + try { + const text = await FileDrop.ClipboardText(); + if (!text) { + setError(t("peers.details.sendClipboard.empty")); + return; + } + await FileDrop.Send(peer.pubKey, [], text); + finishSend(); + } catch (e) { + setError(String(e)); + } + }; + + return ( +
+
+ + +
+ {error &&
{error}
} +
+ ); +}; + const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => { const { t } = useTranslation(); const formatAge = (unix: number, fallback: string): string => { diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index d61221758..4ae61b8b4 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -1,9 +1,21 @@ -import { type KeyboardEvent, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + type DragEvent, + type KeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { useTranslation } from "react-i18next"; +import { Events } from "@wailsio/runtime"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; -import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react"; +import { ChevronRightIcon, MonitorSmartphoneIcon, SendIcon } from "lucide-react"; +import { FileDrop } from "@bindings/services"; import type { PeerStatus } from "@bindings/services/models.js"; +import { useNavSection } from "@/contexts/NavSectionContext"; import { cn } from "@/lib/cn"; import { reconcileOrder } from "@/lib/sorting"; import { CopyToClipboard } from "@/components/CopyToClipboard"; @@ -41,13 +53,36 @@ export const peerStatusLabelKey = (connStatus: string): string => { } }; +const EVENT_FILES_DROPPED = "netbird:files:dropped"; + export const Peers = () => { const { t } = useTranslation(); const { status } = useStatus(); + const { setSection } = useNavSection(); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [scrollParent, setScrollParent] = useState(null); const searchRef = useRef(null); + const [dropTarget, setDropTarget] = useState(null); + const dropTargetRef = useRef(null); + + const setDropTargetBoth = useCallback((pubKey: string | null) => { + dropTargetRef.current = pubKey; + setDropTarget(pubKey); + }, []); + + useEffect(() => { + const off = Events.On(EVENT_FILES_DROPPED, (ev: { data: string[] | string[][] }) => { + const target = dropTargetRef.current; + setDropTargetBoth(null); + if (!target) return; + const raw = ev.data; + const paths = (Array.isArray(raw[0]) ? raw[0] : raw) as string[]; + if (paths.length === 0) return; + void FileDrop.Send(target, paths, "").then(() => setSection("files")); + }); + return off; + }, [setDropTargetBoth, setSection]); useEffect(() => { searchRef.current?.focus(); @@ -135,9 +170,26 @@ export const Peers = () => { {filtered.length === 0 ? ( ) : ( - + { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + setDropTargetBoth(null); + } + }} + onDragOver={(e: DragEvent) => e.preventDefault()} + onDrop={(e: DragEvent) => e.preventDefault()} + > - {scrollParent && } + {scrollParent && ( + + )}
; type PeersListProps = { data: PeerStatus[]; scrollParent: HTMLElement; + dropTarget: string | null; + onDropTarget: (pubKey: string | null) => void; }; -const PeersList = ({ data, scrollParent }: PeersListProps) => { +const PeersList = ({ data, scrollParent, dropTarget, onDropTarget }: PeersListProps) => { const { setSelected } = usePeerDetail(); const virtuosoRef = useRef(null); const rowRefs = useRef>(new Map()); @@ -221,9 +275,15 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => { }; const ctx = useMemo( - () => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }), + () => ({ + onKeyDown: handleRowKeyDown, + onSelect: setSelected, + setRowRef, + dropTarget, + onDropTarget, + }), // eslint-disable-next-line react-hooks/exhaustive-deps - [data, setSelected], + [data, setSelected, dropTarget, onDropTarget], ); return ( @@ -244,6 +304,8 @@ type PeerRowContext = { onKeyDown: (e: KeyboardEvent, index: number) => void; onSelect: (peer: PeerStatus) => void; setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void; + dropTarget: string | null; + onDropTarget: (pubKey: string | null) => void; }; const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): ReactNode => ( @@ -253,6 +315,8 @@ const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): Re onKeyDown={ctx.onKeyDown} onSelect={ctx.onSelect} setRowRef={ctx.setRowRef} + isDropTarget={ctx.dropTarget === peer.pubKey} + onDropTarget={ctx.onDropTarget} /> ); @@ -262,9 +326,19 @@ type PeerRowProps = { onKeyDown: (e: KeyboardEvent, index: number) => void; onSelect: (peer: PeerStatus) => void; setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void; + isDropTarget: boolean; + onDropTarget: (pubKey: string | null) => void; }; -const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) => { +const PeerRow = ({ + peer, + index, + onKeyDown, + onSelect, + setRowRef, + isDropTarget, + onDropTarget, +}: PeerRowProps) => { const { t } = useTranslation(); const isConnected = peer.connStatus === "Connected"; const peerName = shortenDns(peer.fqdn) || peer.ip; @@ -272,12 +346,29 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) const handleKey = (e: KeyboardEvent) => onKeyDown(e, index); return (
{ + if (!isConnected) return; + e.preventDefault(); + onDropTarget(peer.pubKey); + }} className={cn( "group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-4", "transition-colors hover:bg-nb-gray-900/40", "wails-no-draggable", )} > + {isDropTarget && ( +
+ + {t("peers.dropToSend")} +
+ )} + ); + })} +
+
+
+ + {t("settings.fileSharing.destination.help")} +
+ + {settings.destinationDir || t("settings.fileSharing.destination.unset")} + + +
+
+
+ + {t("settings.fileSharing.exceptions.help")} + {rules.length === 0 ? ( + + {t("settings.fileSharing.exceptions.empty")} + + ) : ( +
    + {rules.map(([key, rule]) => ( +
  • + {rule === PeerRule.Block ? ( + + ) : ( + + )} + + {peerName(key)} + + + {rule === PeerRule.Block + ? t("settings.fileSharing.exceptions.blocked") + : t("settings.fileSharing.exceptions.always")} + + +
  • + ))} +
+ )} +
+ + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx index 816dcb71f..b958f0ed0 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx @@ -6,6 +6,7 @@ import { useClientVersion } from "@/contexts/ClientVersionContext.tsx"; import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; import { BoltIcon, + FolderDownIcon, InfoIcon, LifeBuoyIcon, NetworkIcon, @@ -49,6 +50,11 @@ export const SettingsNavigation = () => { /> )} + {!features.disableProfiles && ( = { [Tab.General]: , [Tab.Network]: , [Tab.Security]: , + [Tab.FileSharing]: , [Tab.Profiles]: , [Tab.SSH]: , [Tab.Advanced]: , @@ -53,6 +56,7 @@ export const SettingsPage = () => { [Tab.General]: true, [Tab.Network]: editable, [Tab.Security]: editable, + [Tab.FileSharing]: true, [Tab.Profiles]: !features.disableProfiles, [Tab.SSH]: mdm.allowServerSSH ?? editable, [Tab.Advanced]: editable, diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index d02589591..62da9b83d 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1338,5 +1338,174 @@ }, "error.unknown": { "message": "Vorgang fehlgeschlagen." + }, + "nav.files.title": { + "message": "Dateien" + }, + "files.search.placeholder": { + "message": "Übertragungen nach Datei oder Peer suchen" + }, + "files.empty.title": { + "message": "Noch keine Übertragungen" + }, + "files.empty.description": { + "message": "Gesendete und empfangene Dateien erscheinen hier." + }, + "files.group.today": { + "message": "Heute" + }, + "files.group.yesterday": { + "message": "Gestern" + }, + "files.offer.subtitle": { + "message": "{peer} möchte etwas senden" + }, + "files.offer.accept": { + "message": "Annehmen" + }, + "files.offer.decline": { + "message": "Ablehnen" + }, + "files.row.to": { + "message": "an {peer}" + }, + "files.row.from": { + "message": "von {peer}" + }, + "files.state.pending": { + "message": "Wartet" + }, + "files.state.transferring": { + "message": "Überträgt" + }, + "files.state.progress": { + "message": "{percent}%" + }, + "files.state.received": { + "message": "Empfangen" + }, + "files.state.sent": { + "message": "Gesendet" + }, + "files.state.declined": { + "message": "Abgelehnt" + }, + "files.state.expired": { + "message": "Keine Antwort" + }, + "files.state.cancelled": { + "message": "Abgebrochen" + }, + "files.state.failed": { + "message": "Fehlgeschlagen" + }, + "files.action.copy": { + "message": "Kopieren" + }, + "files.action.reveal": { + "message": "Im Ordner anzeigen" + }, + "files.action.cancel": { + "message": "Abbrechen" + }, + "files.action.more": { + "message": "Mehr" + }, + "files.action.open": { + "message": "Öffnen" + }, + "files.action.alwaysAccept": { + "message": "Von diesem Peer immer annehmen" + }, + "files.action.block": { + "message": "Diesen Peer blockieren" + }, + "files.action.delete": { + "message": "Löschen" + }, + "peers.dropToSend": { + "message": "Zum Senden ablegen" + }, + "peers.details.sendFile": { + "message": "Datei senden…" + }, + "peers.details.sendClipboard": { + "message": "Zwischenablage senden" + }, + "peers.details.sendClipboard.empty": { + "message": "Zwischenablage ist leer" + }, + "settings.tabs.fileSharing": { + "message": "Dateifreigabe" + }, + "settings.fileSharing.section": { + "message": "Dateiempfang" + }, + "settings.fileSharing.loading": { + "message": "Lädt…" + }, + "settings.fileSharing.mode.label": { + "message": "Dateien von Peers empfangen" + }, + "settings.fileSharing.mode.help": { + "message": "Wie dieses Gerät eingehende Dateiangebote behandelt." + }, + "settings.fileSharing.mode.off": { + "message": "Aus" + }, + "settings.fileSharing.mode.off.help": { + "message": "Dieses Gerät nimmt keine Dateien an." + }, + "settings.fileSharing.mode.ask": { + "message": "Jedes Mal fragen" + }, + "settings.fileSharing.mode.ask.help": { + "message": "Jedes Angebot fragt zuerst nach Ihrer Zustimmung." + }, + "settings.fileSharing.mode.auto": { + "message": "Automatisch annehmen" + }, + "settings.fileSharing.mode.auto.help": { + "message": "Dateien kommen ohne Interaktion an." + }, + "settings.fileSharing.destination.label": { + "message": "Empfangene Dateien speichern unter" + }, + "settings.fileSharing.destination.help": { + "message": "Empfangene Dateien werden in diesen Ordner zugestellt." + }, + "settings.fileSharing.destination.unset": { + "message": "Nicht festgelegt" + }, + "settings.fileSharing.destination.change": { + "message": "Ändern…" + }, + "settings.fileSharing.exceptions.label": { + "message": "Ausnahmen pro Peer" + }, + "settings.fileSharing.exceptions.help": { + "message": "Überschreibungen zusätzlich zum Empfangsmodus." + }, + "settings.fileSharing.exceptions.empty": { + "message": "Keine Ausnahmen." + }, + "settings.fileSharing.exceptions.blocked": { + "message": "Blockiert" + }, + "settings.fileSharing.exceptions.always": { + "message": "Immer angenommen" + }, + "settings.fileSharing.exceptions.remove": { + "message": "Ausnahme entfernen" + }, + "files.state.unreachable": { + "message": "Abgelehnt", + "description": "Failed-transfer reason: the peer's file drop port refused the offer." + }, + "notify.filedrop.offer.title": { + "message": "Eingehende Datei" + }, + "notify.filedrop.always": { + "message": "Immer annehmen" } } diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 694444497..f4693e695 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1810,5 +1810,229 @@ "settings.ssh.privilege.oneWayInverted": { "message": "You can switch this on, but switching it back off needs {actor}:", "description": "Warning under the SSH authentication setting, which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command." + }, + "nav.files.title": { + "message": "Files", + "description": "Navigation label for the Files section (file transfers)." + }, + "files.search.placeholder": { + "message": "Search transfers by file or peer", + "description": "Placeholder of the transfer search field." + }, + "files.empty.title": { + "message": "No transfers yet", + "description": "Empty state title of the Files section." + }, + "files.empty.description": { + "message": "Files you send or receive will show up here.", + "description": "Empty state description of the Files section." + }, + "files.group.today": { + "message": "Today", + "description": "Day group header for today's transfers." + }, + "files.group.yesterday": { + "message": "Yesterday", + "description": "Day group header for yesterday's transfers." + }, + "files.offer.subtitle": { + "message": "{peer} wants to send", + "description": "Subtitle of a pending incoming offer. {peer} is the sender's name." + }, + "files.offer.accept": { + "message": "Accept", + "description": "Accept button of a pending offer." + }, + "files.offer.decline": { + "message": "Decline", + "description": "Decline button of a pending offer." + }, + "files.row.to": { + "message": "to {peer}", + "description": "Direction label of a sent transfer. {peer} is the receiver's name." + }, + "files.row.from": { + "message": "from {peer}", + "description": "Direction label of a received transfer. {peer} is the sender's name." + }, + "files.state.pending": { + "message": "Waiting", + "description": "Transfer state: waiting for the receiver's decision." + }, + "files.state.transferring": { + "message": "Transferring", + "description": "Transfer state: payload is streaming." + }, + "files.state.progress": { + "message": "{percent}%", + "description": "Transfer progress. {percent} is a number." + }, + "files.state.received": { + "message": "Received", + "description": "Transfer state: completed incoming transfer." + }, + "files.state.sent": { + "message": "Sent", + "description": "Transfer state: completed outgoing transfer." + }, + "files.state.declined": { + "message": "Declined", + "description": "Transfer state: the receiver declined." + }, + "files.state.expired": { + "message": "No response", + "description": "Transfer state: the offer expired unanswered." + }, + "files.state.cancelled": { + "message": "Cancelled", + "description": "Transfer state: cancelled by a user." + }, + "files.state.failed": { + "message": "Failed", + "description": "Transfer state: the transfer failed." + }, + "files.action.copy": { + "message": "Copy", + "description": "Copy a received text snippet." + }, + "files.action.reveal": { + "message": "Show in folder", + "description": "Open the file manager at the delivered file." + }, + "files.action.cancel": { + "message": "Cancel", + "description": "Cancel a running transfer." + }, + "files.action.more": { + "message": "More", + "description": "Open the row's overflow menu." + }, + "files.action.open": { + "message": "Open", + "description": "Open the delivered file." + }, + "files.action.alwaysAccept": { + "message": "Always accept from this peer", + "description": "Per-sender exception shortcut." + }, + "files.action.block": { + "message": "Block this peer", + "description": "Per-sender block shortcut." + }, + "files.action.delete": { + "message": "Delete", + "description": "Remove the entry from the history." + }, + "peers.dropToSend": { + "message": "Drop to send", + "description": "Overlay shown while dragging files over a peer row." + }, + "peers.details.sendFile": { + "message": "Send file…", + "description": "Peer action opening the file picker." + }, + "peers.details.sendClipboard": { + "message": "Send clipboard", + "description": "Peer action sending the clipboard text." + }, + "peers.details.sendClipboard.empty": { + "message": "Clipboard is empty", + "description": "Error when the clipboard has no text." + }, + "settings.tabs.fileSharing": { + "message": "File sharing", + "description": "Settings tab for file transfer policy." + }, + "settings.fileSharing.section": { + "message": "File receiving", + "description": "Section title of the file receiving policy." + }, + "settings.fileSharing.loading": { + "message": "Loading…", + "description": "Shown while the policy is being fetched." + }, + "settings.fileSharing.mode.label": { + "message": "Receive files from peers", + "description": "Label of the receiving mode selector." + }, + "settings.fileSharing.mode.help": { + "message": "How this device handles incoming file offers.", + "description": "Help text of the receiving mode selector." + }, + "settings.fileSharing.mode.off": { + "message": "Off", + "description": "Receiving mode: no files are accepted." + }, + "settings.fileSharing.mode.off.help": { + "message": "This device does not accept files.", + "description": "Help text of the Off mode." + }, + "settings.fileSharing.mode.ask": { + "message": "Ask every time", + "description": "Receiving mode: each offer asks for consent." + }, + "settings.fileSharing.mode.ask.help": { + "message": "Every offer asks for your consent first.", + "description": "Help text of the Ask mode." + }, + "settings.fileSharing.mode.auto": { + "message": "Auto-accept", + "description": "Receiving mode: files arrive without interaction." + }, + "settings.fileSharing.mode.auto.help": { + "message": "Files arrive without interaction.", + "description": "Help text of the Auto-accept mode." + }, + "settings.fileSharing.destination.label": { + "message": "Save received files to", + "description": "Label of the delivery directory row." + }, + "settings.fileSharing.destination.help": { + "message": "Received files are delivered into this folder.", + "description": "Help text of the delivery directory row." + }, + "settings.fileSharing.destination.unset": { + "message": "Not set", + "description": "Placeholder when no delivery directory is configured." + }, + "settings.fileSharing.destination.change": { + "message": "Change…", + "description": "Button opening the directory picker." + }, + "settings.fileSharing.exceptions.label": { + "message": "Per-peer exceptions", + "description": "Label of the exception list." + }, + "settings.fileSharing.exceptions.help": { + "message": "Overrides on top of the receiving mode.", + "description": "Help text of the exception list." + }, + "settings.fileSharing.exceptions.empty": { + "message": "No exceptions.", + "description": "Shown when the exception list is empty." + }, + "settings.fileSharing.exceptions.blocked": { + "message": "Blocked", + "description": "Exception kind: sender is blocked." + }, + "settings.fileSharing.exceptions.always": { + "message": "Always accepted", + "description": "Exception kind: sender is always accepted." + }, + "settings.fileSharing.exceptions.remove": { + "message": "Remove exception", + "description": "Button removing one exception." + }, + "files.state.unreachable": { + "message": "Declined", + "description": "Failed-transfer reason: the peer's file drop port refused the offer." + }, + "notify.filedrop.offer.title": { + "message": "Incoming file", + "description": "Title of the consent notification for an incoming file offer." + }, + "notify.filedrop.always": { + "message": "Always accept", + "description": "Consent-notification button: accept this offer and auto-accept this sender from now on." } } diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 3420b612b..835aba62c 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1338,5 +1338,174 @@ }, "error.unknown": { "message": "La operación falló." + }, + "nav.files.title": { + "message": "Archivos" + }, + "files.search.placeholder": { + "message": "Buscar transferencias por archivo o peer" + }, + "files.empty.title": { + "message": "Aún no hay transferencias" + }, + "files.empty.description": { + "message": "Los archivos que envíes o recibas aparecerán aquí." + }, + "files.group.today": { + "message": "Hoy" + }, + "files.group.yesterday": { + "message": "Ayer" + }, + "files.offer.subtitle": { + "message": "{peer} quiere enviar" + }, + "files.offer.accept": { + "message": "Aceptar" + }, + "files.offer.decline": { + "message": "Rechazar" + }, + "files.row.to": { + "message": "a {peer}" + }, + "files.row.from": { + "message": "de {peer}" + }, + "files.state.pending": { + "message": "Esperando" + }, + "files.state.transferring": { + "message": "Transfiriendo" + }, + "files.state.progress": { + "message": "{percent}%" + }, + "files.state.received": { + "message": "Recibido" + }, + "files.state.sent": { + "message": "Enviado" + }, + "files.state.declined": { + "message": "Rechazado" + }, + "files.state.expired": { + "message": "Sin respuesta" + }, + "files.state.cancelled": { + "message": "Cancelado" + }, + "files.state.failed": { + "message": "Fallido" + }, + "files.action.copy": { + "message": "Copiar" + }, + "files.action.reveal": { + "message": "Mostrar en la carpeta" + }, + "files.action.cancel": { + "message": "Cancelar" + }, + "files.action.more": { + "message": "Más" + }, + "files.action.open": { + "message": "Abrir" + }, + "files.action.alwaysAccept": { + "message": "Aceptar siempre de este peer" + }, + "files.action.block": { + "message": "Bloquear este peer" + }, + "files.action.delete": { + "message": "Eliminar" + }, + "peers.dropToSend": { + "message": "Suelta para enviar" + }, + "peers.details.sendFile": { + "message": "Enviar archivo…" + }, + "peers.details.sendClipboard": { + "message": "Enviar portapapeles" + }, + "peers.details.sendClipboard.empty": { + "message": "El portapapeles está vacío" + }, + "settings.tabs.fileSharing": { + "message": "Compartir archivos" + }, + "settings.fileSharing.section": { + "message": "Recepción de archivos" + }, + "settings.fileSharing.loading": { + "message": "Cargando…" + }, + "settings.fileSharing.mode.label": { + "message": "Recibir archivos de peers" + }, + "settings.fileSharing.mode.help": { + "message": "Cómo maneja este dispositivo las ofertas de archivos entrantes." + }, + "settings.fileSharing.mode.off": { + "message": "Desactivado" + }, + "settings.fileSharing.mode.off.help": { + "message": "Este dispositivo no acepta archivos." + }, + "settings.fileSharing.mode.ask": { + "message": "Preguntar cada vez" + }, + "settings.fileSharing.mode.ask.help": { + "message": "Cada oferta pide primero tu consentimiento." + }, + "settings.fileSharing.mode.auto": { + "message": "Aceptar automáticamente" + }, + "settings.fileSharing.mode.auto.help": { + "message": "Los archivos llegan sin interacción." + }, + "settings.fileSharing.destination.label": { + "message": "Guardar archivos recibidos en" + }, + "settings.fileSharing.destination.help": { + "message": "Los archivos recibidos se entregan en esta carpeta." + }, + "settings.fileSharing.destination.unset": { + "message": "Sin configurar" + }, + "settings.fileSharing.destination.change": { + "message": "Cambiar…" + }, + "settings.fileSharing.exceptions.label": { + "message": "Excepciones por peer" + }, + "settings.fileSharing.exceptions.help": { + "message": "Anulaciones sobre el modo de recepción." + }, + "settings.fileSharing.exceptions.empty": { + "message": "Sin excepciones." + }, + "settings.fileSharing.exceptions.blocked": { + "message": "Bloqueado" + }, + "settings.fileSharing.exceptions.always": { + "message": "Siempre aceptado" + }, + "settings.fileSharing.exceptions.remove": { + "message": "Eliminar excepción" + }, + "files.state.unreachable": { + "message": "Rechazado", + "description": "Failed-transfer reason: the peer's file drop port refused the offer." + }, + "notify.filedrop.offer.title": { + "message": "Archivo entrante" + }, + "notify.filedrop.always": { + "message": "Aceptar siempre" } } diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index a83f85c12..f812294a4 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1338,5 +1338,174 @@ }, "error.unknown": { "message": "L’opération a échoué." + }, + "nav.files.title": { + "message": "Fichiers" + }, + "files.search.placeholder": { + "message": "Rechercher des transferts par fichier ou pair" + }, + "files.empty.title": { + "message": "Aucun transfert pour l'instant" + }, + "files.empty.description": { + "message": "Les fichiers envoyés ou reçus apparaîtront ici." + }, + "files.group.today": { + "message": "Aujourd'hui" + }, + "files.group.yesterday": { + "message": "Hier" + }, + "files.offer.subtitle": { + "message": "{peer} veut envoyer" + }, + "files.offer.accept": { + "message": "Accepter" + }, + "files.offer.decline": { + "message": "Refuser" + }, + "files.row.to": { + "message": "vers {peer}" + }, + "files.row.from": { + "message": "de {peer}" + }, + "files.state.pending": { + "message": "En attente" + }, + "files.state.transferring": { + "message": "Transfert" + }, + "files.state.progress": { + "message": "{percent}%" + }, + "files.state.received": { + "message": "Reçu" + }, + "files.state.sent": { + "message": "Envoyé" + }, + "files.state.declined": { + "message": "Refusé" + }, + "files.state.expired": { + "message": "Sans réponse" + }, + "files.state.cancelled": { + "message": "Annulé" + }, + "files.state.failed": { + "message": "Échoué" + }, + "files.action.copy": { + "message": "Copier" + }, + "files.action.reveal": { + "message": "Afficher dans le dossier" + }, + "files.action.cancel": { + "message": "Annuler" + }, + "files.action.more": { + "message": "Plus" + }, + "files.action.open": { + "message": "Ouvrir" + }, + "files.action.alwaysAccept": { + "message": "Toujours accepter de ce pair" + }, + "files.action.block": { + "message": "Bloquer ce pair" + }, + "files.action.delete": { + "message": "Supprimer" + }, + "peers.dropToSend": { + "message": "Déposer pour envoyer" + }, + "peers.details.sendFile": { + "message": "Envoyer un fichier…" + }, + "peers.details.sendClipboard": { + "message": "Envoyer le presse-papiers" + }, + "peers.details.sendClipboard.empty": { + "message": "Le presse-papiers est vide" + }, + "settings.tabs.fileSharing": { + "message": "Partage de fichiers" + }, + "settings.fileSharing.section": { + "message": "Réception de fichiers" + }, + "settings.fileSharing.loading": { + "message": "Chargement…" + }, + "settings.fileSharing.mode.label": { + "message": "Recevoir des fichiers des pairs" + }, + "settings.fileSharing.mode.help": { + "message": "Comment cet appareil traite les offres de fichiers entrantes." + }, + "settings.fileSharing.mode.off": { + "message": "Désactivé" + }, + "settings.fileSharing.mode.off.help": { + "message": "Cet appareil n'accepte pas de fichiers." + }, + "settings.fileSharing.mode.ask": { + "message": "Demander à chaque fois" + }, + "settings.fileSharing.mode.ask.help": { + "message": "Chaque offre demande d'abord votre accord." + }, + "settings.fileSharing.mode.auto": { + "message": "Acceptation automatique" + }, + "settings.fileSharing.mode.auto.help": { + "message": "Les fichiers arrivent sans interaction." + }, + "settings.fileSharing.destination.label": { + "message": "Enregistrer les fichiers reçus dans" + }, + "settings.fileSharing.destination.help": { + "message": "Les fichiers reçus sont déposés dans ce dossier." + }, + "settings.fileSharing.destination.unset": { + "message": "Non défini" + }, + "settings.fileSharing.destination.change": { + "message": "Modifier…" + }, + "settings.fileSharing.exceptions.label": { + "message": "Exceptions par pair" + }, + "settings.fileSharing.exceptions.help": { + "message": "Dérogations au mode de réception." + }, + "settings.fileSharing.exceptions.empty": { + "message": "Aucune exception." + }, + "settings.fileSharing.exceptions.blocked": { + "message": "Bloqué" + }, + "settings.fileSharing.exceptions.always": { + "message": "Toujours accepté" + }, + "settings.fileSharing.exceptions.remove": { + "message": "Supprimer l'exception" + }, + "files.state.unreachable": { + "message": "Refusé", + "description": "Failed-transfer reason: the peer's file drop port refused the offer." + }, + "notify.filedrop.offer.title": { + "message": "Fichier entrant" + }, + "notify.filedrop.always": { + "message": "Toujours accepter" } } diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index b291f7a01..29c15d8f6 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1338,5 +1338,174 @@ }, "error.unknown": { "message": "A művelet meghiúsult." + }, + "nav.files.title": { + "message": "Fájlok" + }, + "files.search.placeholder": { + "message": "Keresés fájl vagy peer szerint" + }, + "files.empty.title": { + "message": "Még nincs átvitel" + }, + "files.empty.description": { + "message": "Az elküldött és fogadott fájlok itt jelennek meg." + }, + "files.group.today": { + "message": "Ma" + }, + "files.group.yesterday": { + "message": "Tegnap" + }, + "files.offer.subtitle": { + "message": "{peer} küldeni szeretne" + }, + "files.offer.accept": { + "message": "Elfogadás" + }, + "files.offer.decline": { + "message": "Elutasítás" + }, + "files.row.to": { + "message": "ide: {peer}" + }, + "files.row.from": { + "message": "tőle: {peer}" + }, + "files.state.pending": { + "message": "Várakozás" + }, + "files.state.transferring": { + "message": "Átvitel folyamatban" + }, + "files.state.progress": { + "message": "{percent}%" + }, + "files.state.received": { + "message": "Fogadva" + }, + "files.state.sent": { + "message": "Elküldve" + }, + "files.state.declined": { + "message": "Elutasítva" + }, + "files.state.expired": { + "message": "Nincs válasz" + }, + "files.state.cancelled": { + "message": "Megszakítva" + }, + "files.state.failed": { + "message": "Sikertelen" + }, + "files.action.copy": { + "message": "Másolás" + }, + "files.action.reveal": { + "message": "Megjelenítés a mappában" + }, + "files.action.cancel": { + "message": "Megszakítás" + }, + "files.action.more": { + "message": "Továbbiak" + }, + "files.action.open": { + "message": "Megnyitás" + }, + "files.action.alwaysAccept": { + "message": "Mindig elfogadás ettől a peertől" + }, + "files.action.block": { + "message": "Peer tiltása" + }, + "files.action.delete": { + "message": "Törlés" + }, + "peers.dropToSend": { + "message": "Engedd el a küldéshez" + }, + "peers.details.sendFile": { + "message": "Fájl küldése…" + }, + "peers.details.sendClipboard": { + "message": "Vágólap küldése" + }, + "peers.details.sendClipboard.empty": { + "message": "A vágólap üres" + }, + "settings.tabs.fileSharing": { + "message": "Fájlmegosztás" + }, + "settings.fileSharing.section": { + "message": "Fájlfogadás" + }, + "settings.fileSharing.loading": { + "message": "Betöltés…" + }, + "settings.fileSharing.mode.label": { + "message": "Fájlok fogadása peerektől" + }, + "settings.fileSharing.mode.help": { + "message": "Így kezeli az eszköz a bejövő fájlfelajánlásokat." + }, + "settings.fileSharing.mode.off": { + "message": "Kikapcsolva" + }, + "settings.fileSharing.mode.off.help": { + "message": "Az eszköz nem fogad fájlokat." + }, + "settings.fileSharing.mode.ask": { + "message": "Mindig kérdezzen" + }, + "settings.fileSharing.mode.ask.help": { + "message": "Minden felajánlás először engedélyt kér." + }, + "settings.fileSharing.mode.auto": { + "message": "Automatikus elfogadás" + }, + "settings.fileSharing.mode.auto.help": { + "message": "A fájlok beavatkozás nélkül érkeznek." + }, + "settings.fileSharing.destination.label": { + "message": "Fogadott fájlok mentése ide" + }, + "settings.fileSharing.destination.help": { + "message": "A fogadott fájlok ebbe a mappába kerülnek." + }, + "settings.fileSharing.destination.unset": { + "message": "Nincs beállítva" + }, + "settings.fileSharing.destination.change": { + "message": "Módosítás…" + }, + "settings.fileSharing.exceptions.label": { + "message": "Peerenkénti kivételek" + }, + "settings.fileSharing.exceptions.help": { + "message": "A fogadási módot felülíró szabályok." + }, + "settings.fileSharing.exceptions.empty": { + "message": "Nincsenek kivételek." + }, + "settings.fileSharing.exceptions.blocked": { + "message": "Tiltva" + }, + "settings.fileSharing.exceptions.always": { + "message": "Mindig elfogadva" + }, + "settings.fileSharing.exceptions.remove": { + "message": "Kivétel eltávolítása" + }, + "files.state.unreachable": { + "message": "Elutasítva", + "description": "Failed-transfer reason: the peer's file drop port refused the offer." + }, + "notify.filedrop.offer.title": { + "message": "Bejövő fájl" + }, + "notify.filedrop.always": { + "message": "Mindig elfogadás" } } diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index a68a8b32b..bdcb94547 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1338,5 +1338,174 @@ }, "error.unknown": { "message": "Operazione non riuscita." + }, + "nav.files.title": { + "message": "File" + }, + "files.search.placeholder": { + "message": "Cerca trasferimenti per file o peer" + }, + "files.empty.title": { + "message": "Nessun trasferimento" + }, + "files.empty.description": { + "message": "I file inviati o ricevuti appariranno qui." + }, + "files.group.today": { + "message": "Oggi" + }, + "files.group.yesterday": { + "message": "Ieri" + }, + "files.offer.subtitle": { + "message": "{peer} vuole inviare" + }, + "files.offer.accept": { + "message": "Accetta" + }, + "files.offer.decline": { + "message": "Rifiuta" + }, + "files.row.to": { + "message": "a {peer}" + }, + "files.row.from": { + "message": "da {peer}" + }, + "files.state.pending": { + "message": "In attesa" + }, + "files.state.transferring": { + "message": "Trasferimento" + }, + "files.state.progress": { + "message": "{percent}%" + }, + "files.state.received": { + "message": "Ricevuto" + }, + "files.state.sent": { + "message": "Inviato" + }, + "files.state.declined": { + "message": "Rifiutato" + }, + "files.state.expired": { + "message": "Nessuna risposta" + }, + "files.state.cancelled": { + "message": "Annullato" + }, + "files.state.failed": { + "message": "Non riuscito" + }, + "files.action.copy": { + "message": "Copia" + }, + "files.action.reveal": { + "message": "Mostra nella cartella" + }, + "files.action.cancel": { + "message": "Annulla" + }, + "files.action.more": { + "message": "Altro" + }, + "files.action.open": { + "message": "Apri" + }, + "files.action.alwaysAccept": { + "message": "Accetta sempre da questo peer" + }, + "files.action.block": { + "message": "Blocca questo peer" + }, + "files.action.delete": { + "message": "Elimina" + }, + "peers.dropToSend": { + "message": "Rilascia per inviare" + }, + "peers.details.sendFile": { + "message": "Invia file…" + }, + "peers.details.sendClipboard": { + "message": "Invia appunti" + }, + "peers.details.sendClipboard.empty": { + "message": "Gli appunti sono vuoti" + }, + "settings.tabs.fileSharing": { + "message": "Condivisione file" + }, + "settings.fileSharing.section": { + "message": "Ricezione file" + }, + "settings.fileSharing.loading": { + "message": "Caricamento…" + }, + "settings.fileSharing.mode.label": { + "message": "Ricevi file dai peer" + }, + "settings.fileSharing.mode.help": { + "message": "Come questo dispositivo gestisce le offerte di file in arrivo." + }, + "settings.fileSharing.mode.off": { + "message": "Disattivato" + }, + "settings.fileSharing.mode.off.help": { + "message": "Questo dispositivo non accetta file." + }, + "settings.fileSharing.mode.ask": { + "message": "Chiedi ogni volta" + }, + "settings.fileSharing.mode.ask.help": { + "message": "Ogni offerta chiede prima il tuo consenso." + }, + "settings.fileSharing.mode.auto": { + "message": "Accettazione automatica" + }, + "settings.fileSharing.mode.auto.help": { + "message": "I file arrivano senza interazione." + }, + "settings.fileSharing.destination.label": { + "message": "Salva i file ricevuti in" + }, + "settings.fileSharing.destination.help": { + "message": "I file ricevuti vengono consegnati in questa cartella." + }, + "settings.fileSharing.destination.unset": { + "message": "Non impostato" + }, + "settings.fileSharing.destination.change": { + "message": "Cambia…" + }, + "settings.fileSharing.exceptions.label": { + "message": "Eccezioni per peer" + }, + "settings.fileSharing.exceptions.help": { + "message": "Sostituzioni rispetto alla modalità di ricezione." + }, + "settings.fileSharing.exceptions.empty": { + "message": "Nessuna eccezione." + }, + "settings.fileSharing.exceptions.blocked": { + "message": "Bloccato" + }, + "settings.fileSharing.exceptions.always": { + "message": "Sempre accettato" + }, + "settings.fileSharing.exceptions.remove": { + "message": "Rimuovi eccezione" + }, + "files.state.unreachable": { + "message": "Rifiutato", + "description": "Failed-transfer reason: the peer's file drop port refused the offer." + }, + "notify.filedrop.offer.title": { + "message": "File in arrivo" + }, + "notify.filedrop.always": { + "message": "Accetta sempre" } } diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index ec69de9a5..d00ed0ccf 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -1338,5 +1338,174 @@ }, "error.unknown": { "message": "操作に失敗しました。" + }, + "nav.files.title": { + "message": "ファイル" + }, + "files.search.placeholder": { + "message": "ファイルまたはピアで転送を検索" + }, + "files.empty.title": { + "message": "転送はまだありません" + }, + "files.empty.description": { + "message": "送受信したファイルがここに表示されます。" + }, + "files.group.today": { + "message": "今日" + }, + "files.group.yesterday": { + "message": "昨日" + }, + "files.offer.subtitle": { + "message": "{peer} が送信を希望しています" + }, + "files.offer.accept": { + "message": "承認" + }, + "files.offer.decline": { + "message": "拒否" + }, + "files.row.to": { + "message": "{peer} へ" + }, + "files.row.from": { + "message": "{peer} から" + }, + "files.state.pending": { + "message": "待機中" + }, + "files.state.transferring": { + "message": "転送中" + }, + "files.state.progress": { + "message": "{percent}%" + }, + "files.state.received": { + "message": "受信済み" + }, + "files.state.sent": { + "message": "送信済み" + }, + "files.state.declined": { + "message": "拒否されました" + }, + "files.state.expired": { + "message": "応答なし" + }, + "files.state.cancelled": { + "message": "キャンセル済み" + }, + "files.state.failed": { + "message": "失敗" + }, + "files.action.copy": { + "message": "コピー" + }, + "files.action.reveal": { + "message": "フォルダーで表示" + }, + "files.action.cancel": { + "message": "キャンセル" + }, + "files.action.more": { + "message": "その他" + }, + "files.action.open": { + "message": "開く" + }, + "files.action.alwaysAccept": { + "message": "このピアからは常に承認" + }, + "files.action.block": { + "message": "このピアをブロック" + }, + "files.action.delete": { + "message": "削除" + }, + "peers.dropToSend": { + "message": "ドロップして送信" + }, + "peers.details.sendFile": { + "message": "ファイルを送信…" + }, + "peers.details.sendClipboard": { + "message": "クリップボードを送信" + }, + "peers.details.sendClipboard.empty": { + "message": "クリップボードは空です" + }, + "settings.tabs.fileSharing": { + "message": "ファイル共有" + }, + "settings.fileSharing.section": { + "message": "ファイル受信" + }, + "settings.fileSharing.loading": { + "message": "読み込み中…" + }, + "settings.fileSharing.mode.label": { + "message": "ピアからファイルを受信" + }, + "settings.fileSharing.mode.help": { + "message": "このデバイスが受信ファイルの提案を処理する方法。" + }, + "settings.fileSharing.mode.off": { + "message": "オフ" + }, + "settings.fileSharing.mode.off.help": { + "message": "このデバイスはファイルを受け付けません。" + }, + "settings.fileSharing.mode.ask": { + "message": "毎回確認" + }, + "settings.fileSharing.mode.ask.help": { + "message": "各提案はまず同意を求めます。" + }, + "settings.fileSharing.mode.auto": { + "message": "自動承認" + }, + "settings.fileSharing.mode.auto.help": { + "message": "ファイルは操作なしで届きます。" + }, + "settings.fileSharing.destination.label": { + "message": "受信ファイルの保存先" + }, + "settings.fileSharing.destination.help": { + "message": "受信したファイルはこのフォルダーに保存されます。" + }, + "settings.fileSharing.destination.unset": { + "message": "未設定" + }, + "settings.fileSharing.destination.change": { + "message": "変更…" + }, + "settings.fileSharing.exceptions.label": { + "message": "ピアごとの例外" + }, + "settings.fileSharing.exceptions.help": { + "message": "受信モードを上書きする設定。" + }, + "settings.fileSharing.exceptions.empty": { + "message": "例外はありません。" + }, + "settings.fileSharing.exceptions.blocked": { + "message": "ブロック済み" + }, + "settings.fileSharing.exceptions.always": { + "message": "常に承認" + }, + "settings.fileSharing.exceptions.remove": { + "message": "例外を削除" + }, + "files.state.unreachable": { + "message": "拒否されました", + "description": "Failed-transfer reason: the peer's file drop port refused the offer." + }, + "notify.filedrop.offer.title": { + "message": "受信ファイル" + }, + "notify.filedrop.always": { + "message": "常に承認" } } diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index ef1bfd372..2ce9b72e9 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1338,5 +1338,174 @@ }, "error.unknown": { "message": "A operação falhou." + }, + "nav.files.title": { + "message": "Arquivos" + }, + "files.search.placeholder": { + "message": "Buscar transferências por arquivo ou peer" + }, + "files.empty.title": { + "message": "Nenhuma transferência ainda" + }, + "files.empty.description": { + "message": "Os arquivos enviados ou recebidos aparecerão aqui." + }, + "files.group.today": { + "message": "Hoje" + }, + "files.group.yesterday": { + "message": "Ontem" + }, + "files.offer.subtitle": { + "message": "{peer} quer enviar" + }, + "files.offer.accept": { + "message": "Aceitar" + }, + "files.offer.decline": { + "message": "Recusar" + }, + "files.row.to": { + "message": "para {peer}" + }, + "files.row.from": { + "message": "de {peer}" + }, + "files.state.pending": { + "message": "Aguardando" + }, + "files.state.transferring": { + "message": "Transferindo" + }, + "files.state.progress": { + "message": "{percent}%" + }, + "files.state.received": { + "message": "Recebido" + }, + "files.state.sent": { + "message": "Enviado" + }, + "files.state.declined": { + "message": "Recusado" + }, + "files.state.expired": { + "message": "Sem resposta" + }, + "files.state.cancelled": { + "message": "Cancelado" + }, + "files.state.failed": { + "message": "Falhou" + }, + "files.action.copy": { + "message": "Copiar" + }, + "files.action.reveal": { + "message": "Mostrar na pasta" + }, + "files.action.cancel": { + "message": "Cancelar" + }, + "files.action.more": { + "message": "Mais" + }, + "files.action.open": { + "message": "Abrir" + }, + "files.action.alwaysAccept": { + "message": "Sempre aceitar deste peer" + }, + "files.action.block": { + "message": "Bloquear este peer" + }, + "files.action.delete": { + "message": "Excluir" + }, + "peers.dropToSend": { + "message": "Solte para enviar" + }, + "peers.details.sendFile": { + "message": "Enviar arquivo…" + }, + "peers.details.sendClipboard": { + "message": "Enviar área de transferência" + }, + "peers.details.sendClipboard.empty": { + "message": "A área de transferência está vazia" + }, + "settings.tabs.fileSharing": { + "message": "Compartilhamento de arquivos" + }, + "settings.fileSharing.section": { + "message": "Recebimento de arquivos" + }, + "settings.fileSharing.loading": { + "message": "Carregando…" + }, + "settings.fileSharing.mode.label": { + "message": "Receber arquivos de peers" + }, + "settings.fileSharing.mode.help": { + "message": "Como este dispositivo trata ofertas de arquivos recebidas." + }, + "settings.fileSharing.mode.off": { + "message": "Desativado" + }, + "settings.fileSharing.mode.off.help": { + "message": "Este dispositivo não aceita arquivos." + }, + "settings.fileSharing.mode.ask": { + "message": "Perguntar sempre" + }, + "settings.fileSharing.mode.ask.help": { + "message": "Cada oferta pede primeiro o seu consentimento." + }, + "settings.fileSharing.mode.auto": { + "message": "Aceitar automaticamente" + }, + "settings.fileSharing.mode.auto.help": { + "message": "Os arquivos chegam sem interação." + }, + "settings.fileSharing.destination.label": { + "message": "Salvar arquivos recebidos em" + }, + "settings.fileSharing.destination.help": { + "message": "Os arquivos recebidos são entregues nesta pasta." + }, + "settings.fileSharing.destination.unset": { + "message": "Não definido" + }, + "settings.fileSharing.destination.change": { + "message": "Alterar…" + }, + "settings.fileSharing.exceptions.label": { + "message": "Exceções por peer" + }, + "settings.fileSharing.exceptions.help": { + "message": "Substituições sobre o modo de recebimento." + }, + "settings.fileSharing.exceptions.empty": { + "message": "Sem exceções." + }, + "settings.fileSharing.exceptions.blocked": { + "message": "Bloqueado" + }, + "settings.fileSharing.exceptions.always": { + "message": "Sempre aceito" + }, + "settings.fileSharing.exceptions.remove": { + "message": "Remover exceção" + }, + "files.state.unreachable": { + "message": "Recusado", + "description": "Failed-transfer reason: the peer's file drop port refused the offer." + }, + "notify.filedrop.offer.title": { + "message": "Arquivo recebido" + }, + "notify.filedrop.always": { + "message": "Sempre aceitar" } } diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index a876387f4..d5ff1b77a 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1338,5 +1338,174 @@ }, "error.unknown": { "message": "Не удалось выполнить операцию." + }, + "nav.files.title": { + "message": "Файлы" + }, + "files.search.placeholder": { + "message": "Поиск передач по файлу или пиру" + }, + "files.empty.title": { + "message": "Передач пока нет" + }, + "files.empty.description": { + "message": "Отправленные и полученные файлы появятся здесь." + }, + "files.group.today": { + "message": "Сегодня" + }, + "files.group.yesterday": { + "message": "Вчера" + }, + "files.offer.subtitle": { + "message": "{peer} хочет отправить" + }, + "files.offer.accept": { + "message": "Принять" + }, + "files.offer.decline": { + "message": "Отклонить" + }, + "files.row.to": { + "message": "кому: {peer}" + }, + "files.row.from": { + "message": "от {peer}" + }, + "files.state.pending": { + "message": "Ожидание" + }, + "files.state.transferring": { + "message": "Передача" + }, + "files.state.progress": { + "message": "{percent}%" + }, + "files.state.received": { + "message": "Получено" + }, + "files.state.sent": { + "message": "Отправлено" + }, + "files.state.declined": { + "message": "Отклонено" + }, + "files.state.expired": { + "message": "Нет ответа" + }, + "files.state.cancelled": { + "message": "Отменено" + }, + "files.state.failed": { + "message": "Ошибка" + }, + "files.action.copy": { + "message": "Копировать" + }, + "files.action.reveal": { + "message": "Показать в папке" + }, + "files.action.cancel": { + "message": "Отменить" + }, + "files.action.more": { + "message": "Ещё" + }, + "files.action.open": { + "message": "Открыть" + }, + "files.action.alwaysAccept": { + "message": "Всегда принимать от этого пира" + }, + "files.action.block": { + "message": "Заблокировать этого пира" + }, + "files.action.delete": { + "message": "Удалить" + }, + "peers.dropToSend": { + "message": "Отпустите для отправки" + }, + "peers.details.sendFile": { + "message": "Отправить файл…" + }, + "peers.details.sendClipboard": { + "message": "Отправить буфер обмена" + }, + "peers.details.sendClipboard.empty": { + "message": "Буфер обмена пуст" + }, + "settings.tabs.fileSharing": { + "message": "Обмен файлами" + }, + "settings.fileSharing.section": { + "message": "Получение файлов" + }, + "settings.fileSharing.loading": { + "message": "Загрузка…" + }, + "settings.fileSharing.mode.label": { + "message": "Получать файлы от пиров" + }, + "settings.fileSharing.mode.help": { + "message": "Как это устройство обрабатывает входящие предложения файлов." + }, + "settings.fileSharing.mode.off": { + "message": "Выключено" + }, + "settings.fileSharing.mode.off.help": { + "message": "Это устройство не принимает файлы." + }, + "settings.fileSharing.mode.ask": { + "message": "Спрашивать каждый раз" + }, + "settings.fileSharing.mode.ask.help": { + "message": "Каждое предложение сначала запрашивает согласие." + }, + "settings.fileSharing.mode.auto": { + "message": "Автоприём" + }, + "settings.fileSharing.mode.auto.help": { + "message": "Файлы приходят без подтверждения." + }, + "settings.fileSharing.destination.label": { + "message": "Сохранять полученные файлы в" + }, + "settings.fileSharing.destination.help": { + "message": "Полученные файлы помещаются в эту папку." + }, + "settings.fileSharing.destination.unset": { + "message": "Не задано" + }, + "settings.fileSharing.destination.change": { + "message": "Изменить…" + }, + "settings.fileSharing.exceptions.label": { + "message": "Исключения для пиров" + }, + "settings.fileSharing.exceptions.help": { + "message": "Переопределения поверх режима приёма." + }, + "settings.fileSharing.exceptions.empty": { + "message": "Исключений нет." + }, + "settings.fileSharing.exceptions.blocked": { + "message": "Заблокирован" + }, + "settings.fileSharing.exceptions.always": { + "message": "Всегда принимается" + }, + "settings.fileSharing.exceptions.remove": { + "message": "Удалить исключение" + }, + "files.state.unreachable": { + "message": "Отклонено", + "description": "Failed-transfer reason: the peer's file drop port refused the offer." + }, + "notify.filedrop.offer.title": { + "message": "Входящий файл" + }, + "notify.filedrop.always": { + "message": "Всегда принимать" } } diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 542b2b045..b41b7fe11 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1338,5 +1338,174 @@ }, "error.unknown": { "message": "操作失败。" + }, + "nav.files.title": { + "message": "文件" + }, + "files.search.placeholder": { + "message": "按文件或对等设备搜索传输" + }, + "files.empty.title": { + "message": "暂无传输" + }, + "files.empty.description": { + "message": "您发送或接收的文件将显示在这里。" + }, + "files.group.today": { + "message": "今天" + }, + "files.group.yesterday": { + "message": "昨天" + }, + "files.offer.subtitle": { + "message": "{peer} 想要发送" + }, + "files.offer.accept": { + "message": "接受" + }, + "files.offer.decline": { + "message": "拒绝" + }, + "files.row.to": { + "message": "发送至 {peer}" + }, + "files.row.from": { + "message": "来自 {peer}" + }, + "files.state.pending": { + "message": "等待中" + }, + "files.state.transferring": { + "message": "传输中" + }, + "files.state.progress": { + "message": "{percent}%" + }, + "files.state.received": { + "message": "已接收" + }, + "files.state.sent": { + "message": "已发送" + }, + "files.state.declined": { + "message": "已拒绝" + }, + "files.state.expired": { + "message": "无响应" + }, + "files.state.cancelled": { + "message": "已取消" + }, + "files.state.failed": { + "message": "失败" + }, + "files.action.copy": { + "message": "复制" + }, + "files.action.reveal": { + "message": "在文件夹中显示" + }, + "files.action.cancel": { + "message": "取消" + }, + "files.action.more": { + "message": "更多" + }, + "files.action.open": { + "message": "打开" + }, + "files.action.alwaysAccept": { + "message": "始终接受此对等设备" + }, + "files.action.block": { + "message": "屏蔽此对等设备" + }, + "files.action.delete": { + "message": "删除" + }, + "peers.dropToSend": { + "message": "放开即发送" + }, + "peers.details.sendFile": { + "message": "发送文件…" + }, + "peers.details.sendClipboard": { + "message": "发送剪贴板" + }, + "peers.details.sendClipboard.empty": { + "message": "剪贴板为空" + }, + "settings.tabs.fileSharing": { + "message": "文件共享" + }, + "settings.fileSharing.section": { + "message": "文件接收" + }, + "settings.fileSharing.loading": { + "message": "加载中…" + }, + "settings.fileSharing.mode.label": { + "message": "接收对等设备的文件" + }, + "settings.fileSharing.mode.help": { + "message": "此设备如何处理传入的文件提议。" + }, + "settings.fileSharing.mode.off": { + "message": "关闭" + }, + "settings.fileSharing.mode.off.help": { + "message": "此设备不接受文件。" + }, + "settings.fileSharing.mode.ask": { + "message": "每次询问" + }, + "settings.fileSharing.mode.ask.help": { + "message": "每个提议都会先征求您的同意。" + }, + "settings.fileSharing.mode.auto": { + "message": "自动接受" + }, + "settings.fileSharing.mode.auto.help": { + "message": "文件无需交互即可到达。" + }, + "settings.fileSharing.destination.label": { + "message": "接收文件保存至" + }, + "settings.fileSharing.destination.help": { + "message": "接收的文件将存放到此文件夹。" + }, + "settings.fileSharing.destination.unset": { + "message": "未设置" + }, + "settings.fileSharing.destination.change": { + "message": "更改…" + }, + "settings.fileSharing.exceptions.label": { + "message": "按对等设备的例外" + }, + "settings.fileSharing.exceptions.help": { + "message": "覆盖接收模式的规则。" + }, + "settings.fileSharing.exceptions.empty": { + "message": "无例外。" + }, + "settings.fileSharing.exceptions.blocked": { + "message": "已屏蔽" + }, + "settings.fileSharing.exceptions.always": { + "message": "始终接受" + }, + "settings.fileSharing.exceptions.remove": { + "message": "移除例外" + }, + "files.state.unreachable": { + "message": "已拒绝", + "description": "Failed-transfer reason: the peer's file drop port refused the offer." + }, + "notify.filedrop.offer.title": { + "message": "传入文件" + }, + "notify.filedrop.always": { + "message": "始终接受" } } diff --git a/client/ui/main.go b/client/ui/main.go index 5f740f5ec..4a843e03c 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -56,6 +56,7 @@ func (s *stringList) Set(v string) error { type registeredServices struct { connection *services.Connection + fileDrop *services.FileDrop authSession *authsession.Session settings *services.Settings networks *services.Networks @@ -123,9 +124,11 @@ func main() { // the React frontend calls, keeping the generated TS surface minimal. authSession := authsession.NewSession(conn) networks := services.NewNetworks(conn) + fileDrop := services.NewFileDrop(conn) registerServices(app, conn, registeredServices{ connection: connection, + fileDrop: fileDrop, authSession: authSession, settings: settings, networks: networks, @@ -170,6 +173,7 @@ func main() { tray = NewTray(app, window, TrayServices{ Connection: connection, + FileDrop: fileDrop, Settings: settings, Profiles: profiles, Networks: networks, @@ -326,6 +330,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) { app.RegisterService(application.NewService(services.NewForwarding(conn))) app.RegisterService(application.NewService(s.profiles)) app.RegisterService(application.NewService(services.NewDebug(conn))) + app.RegisterService(application.NewService(s.fileDrop)) app.RegisterService(application.NewService(s.update)) app.RegisterService(application.NewService(s.daemonFeed)) app.RegisterService(application.NewService(s.notifier)) @@ -359,6 +364,7 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat BackgroundColour: services.WindowBackgroundColour, URL: "/", DisableResize: true, + EnableFileDrop: true, MinimiseButtonState: application.ButtonHidden, MaximiseButtonState: application.ButtonHidden, Mac: services.AppleMacOSAppearanceOptions(), @@ -368,6 +374,10 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat }, }) + window.RegisterHook(events.Common.WindowFilesDropped, func(e *application.WindowEvent) { + app.Event.Emit(services.EventFilesDropped, e.Context().DroppedFiles()) + }) + // Hide instead of quit on close; "really quit" is reached via tray -> Quit. window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { if services.ShuttingDown() { diff --git a/client/ui/notifier.go b/client/ui/notifier.go index 71ae3b0df..77096be0e 100644 --- a/client/ui/notifier.go +++ b/client/ui/notifier.go @@ -85,6 +85,14 @@ func (n *Notifier) SendNotificationWithActions(options notifications.Notificatio return n.inner.SendNotificationWithActions(options) } +// RemoveNotification withdraws a delivered notification, a no-op without a backend. +func (n *Notifier) RemoveNotification(identifier string) error { + if !n.available.Load() { + return nil + } + return n.inner.RemoveNotification(identifier) +} + func (n *Notifier) RegisterNotificationCategory(category notifications.NotificationCategory) error { if !n.available.Load() { return nil diff --git a/client/ui/services/daemon_feed.go b/client/ui/services/daemon_feed.go index 632581fe9..9629984f5 100644 --- a/client/ui/services/daemon_feed.go +++ b/client/ui/services/daemon_feed.go @@ -33,6 +33,9 @@ const ( // subscribers needn't filter the notification firehose. Consumers branch on // SessionWarning.Final to tell the T-10 event from the T-2 fallback. EventSessionWarning = "netbird:session:warning" + // EventFileDrop is a typed sibling of EventDaemonNotification for file + // transfer milestones; the payload is a FileDropEvent. + EventFileDrop = "netbird:filedrop" // StatusDaemonUnavailable is the synthetic Status emitted when the daemon's // gRPC socket is unreachable. No internal.Status* collides with this label. @@ -57,6 +60,29 @@ type Emitter interface { Emit(name string, data ...any) bool } +// FileDropEvent is the payload of EventFileDrop. Kind is one of "offer", +// "completed", "failed", "withdrawn". +type FileDropEvent struct { + Kind string `json:"kind"` + TransferID string `json:"transferId"` + Message string `json:"message"` +} + +func fileDropEventKind(metaKind string) (string, bool) { + switch metaKind { + case proto.MetadataKindFileDropOffer: + return "offer", true + case proto.MetadataKindFileDropCompleted: + return "completed", true + case proto.MetadataKindFileDropFailed: + return "failed", true + case proto.MetadataKindFileDropWithdrawn: + return "withdrawn", true + default: + return "", false + } +} + // SystemEvent is the frontend-facing shape of a daemon SystemEvent. type SystemEvent struct { ID string `json:"id"` @@ -486,6 +512,16 @@ func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) { } return } + if kind, ok := fileDropEventKind(se.Metadata[proto.MetadataKindKey]); ok { + s.emitter.Emit(EventFileDrop, FileDropEvent{ + Kind: kind, + TransferID: se.Metadata[proto.MetadataFileDropTransferKey], + Message: se.UserMessage, + }) + if se.UserMessage == "" { + return + } + } s.emitter.Emit(EventDaemonNotification, se) if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok { s.emitter.Emit(EventSessionWarning, warn) diff --git a/client/ui/services/filedrop.go b/client/ui/services/filedrop.go new file mode 100644 index 000000000..3cb247356 --- /dev/null +++ b/client/ui/services/filedrop.go @@ -0,0 +1,238 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "errors" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/proto" +) + +// EventFilesDropped carries the absolute paths of files natively dropped on the +// main window. +const EventFilesDropped = "netbird:files:dropped" + +// FileDropFile mirrors one payload item of a transfer. +type FileDropFile struct { + Name string `json:"name"` + Size int64 `json:"size"` + ContentType string `json:"contentType"` + IsText bool `json:"isText"` + Text string `json:"text"` +} + +// FileDropTransfer mirrors proto.FileDropTransfer for the frontend. +type FileDropTransfer struct { + ID string `json:"id"` + Outgoing bool `json:"outgoing"` + PeerKey string `json:"peerKey"` + PeerName string `json:"peerName"` + Files []FileDropFile `json:"files"` + State int32 `json:"state"` + Transferred int64 `json:"transferred"` + TotalSize int64 `json:"totalSize"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeliveredPaths []string `json:"deliveredPaths"` + Error string `json:"error"` + Reason int32 `json:"reason"` +} + +// FileDropSettings mirrors the daemon's receiving policy with ordinal enums. +type FileDropSettings struct { + Mode int32 `json:"mode"` + DestinationDir string `json:"destinationDir"` + PeerRules map[string]int32 `json:"peerRules"` +} + +// FileDrop bridges the daemon's file transfer RPCs to the frontend. +type FileDrop struct { + conn DaemonConn +} + +func NewFileDrop(conn DaemonConn) *FileDrop { + return &FileDrop{conn: conn} +} + +// List returns the transfer history, newest first. +func (s *FileDrop) List(ctx context.Context) ([]FileDropTransfer, error) { + cli, err := s.conn.Client() + if err != nil { + return nil, err + } + resp, err := cli.FileDropListTransfers(ctx, &proto.FileDropListTransfersRequest{}) + if err != nil { + return nil, err + } + + out := make([]FileDropTransfer, 0, len(resp.GetTransfers())) + for _, t := range resp.GetTransfers() { + out = append(out, fileDropTransferFromProto(t)) + } + return out, nil +} + +// Send starts a transfer of local files and/or an inline text to a peer. +func (s *FileDrop) Send(ctx context.Context, peerKey string, paths []string, text string) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + resp, err := cli.FileDropSend(ctx, &proto.FileDropSendRequest{ + PeerKey: peerKey, + Paths: paths, + Text: text, + }) + if err != nil { + return "", err + } + return resp.GetTransferId(), nil +} + +// PickFiles opens the native file picker; an empty result means cancelled. +func (s *FileDrop) PickFiles(_ context.Context) ([]string, error) { + return application.Get().Dialog.OpenFile().PromptForMultipleSelection() +} + +// PickDirectory opens the native directory picker; empty means cancelled. +func (s *FileDrop) PickDirectory(_ context.Context) (string, error) { + return application.Get().Dialog.OpenFile(). + CanChooseDirectories(true). + CanChooseFiles(false). + PromptForSingleSelection() +} + +// Decide accepts or declines a pending incoming offer. +func (s *FileDrop) Decide(ctx context.Context, id string, accept bool) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.FileDropDecide(ctx, &proto.FileDropDecideRequest{TransferId: id, Accept: accept}) + return err +} + +// Cancel aborts a transfer. +func (s *FileDrop) Cancel(ctx context.Context, id string) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.FileDropCancel(ctx, &proto.FileDropCancelRequest{TransferId: id}) + return err +} + +// Delete removes a history entry. +func (s *FileDrop) Delete(ctx context.Context, id string) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.FileDropDeleteTransfer(ctx, &proto.FileDropDeleteTransferRequest{TransferId: id}) + return err +} + +// GetSettings returns the receiving policy of the active profile. +func (s *FileDrop) GetSettings(ctx context.Context) (FileDropSettings, error) { + cli, err := s.conn.Client() + if err != nil { + return FileDropSettings{}, err + } + resp, err := cli.FileDropGetSettings(ctx, &proto.FileDropGetSettingsRequest{}) + if err != nil { + return FileDropSettings{}, err + } + + rules := make(map[string]int32, len(resp.GetPeerRules())) + for key, rule := range resp.GetPeerRules() { + rules[key] = int32(rule) + } + return FileDropSettings{ + Mode: int32(resp.GetMode()), + DestinationDir: resp.GetDestinationDir(), + PeerRules: rules, + }, nil +} + +// SetSettings updates the receiving policy of the active profile. +func (s *FileDrop) SetSettings(ctx context.Context, settings FileDropSettings) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.FileDropSetSettings(ctx, &proto.FileDropSetSettingsRequest{ + Mode: proto.FileDropMode(settings.Mode), + DestinationDir: settings.DestinationDir, + }) + return err +} + +// SetPeerRule sets or clears a per-sender exception. +func (s *FileDrop) SetPeerRule(ctx context.Context, peerKey string, rule int32) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.FileDropSetPeerRule(ctx, &proto.FileDropSetPeerRuleRequest{ + PeerKey: peerKey, + Rule: proto.FileDropRule(rule), + }) + return err +} + +// ClipboardText returns the current clipboard text, empty when unavailable. +func (s *FileDrop) ClipboardText(_ context.Context) string { + text, ok := application.Get().Clipboard.Text() + if !ok { + return "" + } + return text +} + +// Reveal opens the OS file manager focused on a delivered file. +func (s *FileDrop) Reveal(_ context.Context, path string) error { + if path == "" { + return errors.New("empty path") + } + return revealFile(path) +} + +// Open opens a delivered file with its default application. +func (s *FileDrop) Open(_ context.Context, path string) error { + if path == "" { + return errors.New("empty path") + } + return openFile(path) +} + +func fileDropTransferFromProto(t *proto.FileDropTransfer) FileDropTransfer { + files := make([]FileDropFile, 0, len(t.GetFiles())) + for _, f := range t.GetFiles() { + files = append(files, FileDropFile{ + Name: f.GetName(), + Size: f.GetSize(), + ContentType: f.GetContentType(), + IsText: f.GetIsText(), + Text: f.GetText(), + }) + } + return FileDropTransfer{ + ID: t.GetId(), + Outgoing: t.GetOutgoing(), + PeerKey: t.GetPeerKey(), + PeerName: t.GetPeerName(), + Files: files, + State: int32(t.GetState()), + Transferred: t.GetTransferred(), + TotalSize: t.GetTotalSize(), + CreatedAt: t.GetCreatedAt().AsTime(), + UpdatedAt: t.GetUpdatedAt().AsTime(), + DeliveredPaths: append([]string{}, t.GetDeliveredPaths()...), + Error: t.GetError(), + Reason: int32(t.GetReason()), + } +} diff --git a/client/ui/services/openfile_other.go b/client/ui/services/openfile_other.go new file mode 100644 index 000000000..0ef099b59 --- /dev/null +++ b/client/ui/services/openfile_other.go @@ -0,0 +1,16 @@ +//go:build !android && !ios && !freebsd && !js && !windows + +package services + +import ( + "os/exec" + "runtime" +) + +func openFile(path string) error { + opener := "xdg-open" + if runtime.GOOS == "darwin" { + opener = "open" + } + return exec.Command(opener, path).Start() +} diff --git a/client/ui/services/openfile_windows.go b/client/ui/services/openfile_windows.go new file mode 100644 index 000000000..ec6bd865e --- /dev/null +++ b/client/ui/services/openfile_windows.go @@ -0,0 +1,9 @@ +package services + +import ( + "os/exec" +) + +func openFile(path string) error { + return exec.Command("rundll32", "url.dll,FileProtocolHandler", path).Start() //nolint:gosec +} diff --git a/client/ui/tray.go b/client/ui/tray.go index 148dd50b3..8ef01bef5 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -41,6 +41,7 @@ const ( // stays under the linter's parameter-count threshold. type TrayServices struct { Connection *services.Connection + FileDrop *services.FileDrop Settings *services.Settings Profiles *services.Profiles Networks *services.Networks @@ -200,6 +201,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe app.Event.On(services.EventStatusSnapshot, t.onStatusEvent) app.Event.On(services.EventDaemonNotification, t.onSystemEvent) + app.Event.On(services.EventFileDrop, t.onFileDropEvent) // Refresh the Profiles submenu on ProfileSwitcher's change event. A // switch on an idle daemon drives no status transition, so without this // hook a React-initiated switch leaves the tray's submenu stale. @@ -217,6 +219,8 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe // Startup populates appName/registry path on Windows; before app.Run() // the category lookup silently falls back to a plain notification. t.registerSessionWarningCategory() + t.registerFileDropCategory() + t.registerNotificationResponses() }) t.loc.Watch(func(i18n.LanguageCode) { t.applyLanguage() }) diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go index 12da68a5c..e948b3273 100644 --- a/client/ui/tray_events.go +++ b/client/ui/tray_events.go @@ -65,6 +65,11 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) { // category/severity so a daemon-side reword still lands here. Final warning // auto-opens the SessionExpiration dialog with no notification (the dialog is // the last-chance reminder; doubling up would be noise). + if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindFileDropOffer { + t.notifyFileDropOffer(se) + return + } + if isDeadlineRejected { t.notify( t.loc.T("notify.sessionDeadlineRejected.title"), diff --git a/client/ui/tray_filedrop.go b/client/ui/tray_filedrop.go new file mode 100644 index 000000000..1dff820d9 --- /dev/null +++ b/client/ui/tray_filedrop.go @@ -0,0 +1,146 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/services" +) + +const ( + notifyCategoryFileDropOffer = "netbird-filedrop-offer" + notifyActionFileDropAccept = "filedrop-accept" + notifyActionFileDropDecline = "filedrop-decline" + notifyActionFileDropAlways = "filedrop-always-accept" + notifyIDFileDropPrefix = "netbird-filedrop-" + + fileDropDecideTimeout = 10 * time.Second +) + +// registerFileDropCategory wires the consent-notification category. Errors are +// swallowed: the worst case is a plain notification without buttons. +func (t *Tray) registerFileDropCategory() { + if t.svc.Notifier == nil { + return + } + if err := t.svc.Notifier.RegisterNotificationCategory(notifications.NotificationCategory{ + ID: notifyCategoryFileDropOffer, + Actions: []notifications.NotificationAction{ + {ID: notifyActionFileDropAccept, Title: t.loc.T("files.offer.accept")}, + {ID: notifyActionFileDropDecline, Title: t.loc.T("files.offer.decline")}, + {ID: notifyActionFileDropAlways, Title: t.loc.T("notify.filedrop.always")}, + }, + }); err != nil { + log.Debugf("register file drop notification category: %v", err) + } +} + +// notifyFileDropOffer raises the consent notification with Accept, Decline, and +// Always accept actions, falling back to a plain notification. +func (t *Tray) notifyFileDropOffer(se services.SystemEvent) { + if t.svc.Notifier == nil { + return + } + + transferID := se.Metadata[proto.MetadataFileDropTransferKey] + title := t.loc.T("notify.filedrop.offer.title") + if transferID == "" { + t.notify(title, se.UserMessage, notifyIDEvent+se.ID) + return + } + + err := safeSendNotification(t.svc.Notifier.SendNotificationWithActions, "filedrop offer with actions", notifications.NotificationOptions{ + ID: notifyIDFileDropPrefix + transferID, + Title: title, + Body: se.UserMessage, + CategoryID: notifyCategoryFileDropOffer, + Data: map[string]interface{}{ + proto.MetadataFileDropTransferKey: transferID, + proto.MetadataFileDropPeerKey: se.Metadata[proto.MetadataFileDropPeerKey], + }, + }) + if err != nil { + t.notify(title, se.UserMessage, notifyIDFileDropPrefix+transferID) + } +} + +// handleFileDropResponse acts on the consent-notification buttons. The transfer ID +// is recovered from the notification ID when the platform drops the user info; a +// body click just brings the app forward, so a stray tap can never accept. +func (t *Tray) handleFileDropResponse(resp notifications.NotificationResponse) { + transferID, _ := resp.UserInfo[proto.MetadataFileDropTransferKey].(string) + if transferID == "" && strings.HasPrefix(resp.ID, notifyIDFileDropPrefix) { + transferID = strings.TrimPrefix(resp.ID, notifyIDFileDropPrefix) + } + peerKey, _ := resp.UserInfo[proto.MetadataFileDropPeerKey].(string) + + switch resp.ActionIdentifier { + case notifyActionFileDropAccept: + go t.fileDropDecide(transferID, true) + case notifyActionFileDropDecline: + go t.fileDropDecide(transferID, false) + case notifyActionFileDropAlways: + go t.fileDropAlwaysAccept(transferID, peerKey) + default: + t.ShowWindow() + } +} + +// onFileDropEvent withdraws the consent notification when the sender cancelled the +// offer or it expired. +func (t *Tray) onFileDropEvent(ev *application.CustomEvent) { + fe, ok := ev.Data.(services.FileDropEvent) + if !ok || fe.Kind != "withdrawn" || fe.TransferID == "" { + return + } + t.removeFileDropNotification(fe.TransferID) +} + +func (t *Tray) fileDropDecide(transferID string, accept bool) { + if t.svc.FileDrop == nil || transferID == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), fileDropDecideTimeout) + defer cancel() + if err := t.svc.FileDrop.Decide(ctx, transferID, accept); err != nil { + log.Debugf("file drop decide from notification: %v", err) + } +} + +func (t *Tray) fileDropAlwaysAccept(transferID, peerKey string) { + if t.svc.FileDrop == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), fileDropDecideTimeout) + defer cancel() + if peerKey != "" { + if err := t.svc.FileDrop.SetPeerRule(ctx, peerKey, int32(proto.FileDropRule_FILE_DROP_RULE_ALWAYS)); err != nil { + log.Debugf("file drop always-accept rule from notification: %v", err) + } + } + t.fileDropDecide(transferID, true) +} + +// removeFileDropNotification is panic-guarded like safeSendNotification: a dead +// notification bus on Linux panics inside godbus on any call. +func (t *Tray) removeFileDropNotification(transferID string) { + if t.svc.Notifier == nil || services.ShuttingDown() { + return + } + defer func() { + if r := recover(); r != nil { + log.Errorf("remove filedrop notification: recovered from panic (notification bus unavailable): %v", r) + } + }() + if err := t.svc.Notifier.RemoveNotification(notifyIDFileDropPrefix + transferID); err != nil { + log.Debugf("remove filedrop notification: %v", err) + } +} diff --git a/client/ui/tray_notify.go b/client/ui/tray_notify.go index 5b2629419..bede1e84b 100644 --- a/client/ui/tray_notify.go +++ b/client/ui/tray_notify.go @@ -41,6 +41,27 @@ func safeSendNotification(send sendFn, what string, opts notifications.Notificat return nil } +// registerNotificationResponses installs the single Wails notification-response +// callback and fans it out per category; a second OnNotificationResponse call +// would silently replace the first, so every category must branch here. +func (t *Tray) registerNotificationResponses() { + if t.svc.Notifier == nil { + return + } + t.svc.Notifier.OnNotificationResponse(func(result notifications.NotificationResult) { + if result.Error != nil { + log.Debugf("notification response error: %v", result.Error) + return + } + switch result.Response.CategoryID { + case notifyCategorySessionWarning: + t.handleSessionWarningResponse(result.Response) + case notifyCategoryFileDropOffer: + t.handleFileDropResponse(result.Response) + } + }) +} + // notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it // is reachable but too old for this UI. A probe error means the daemon isn't // reachable (not outdated), so it is left to the normal connection flow. diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go index f25419894..c1be8440a 100644 --- a/client/ui/tray_session.go +++ b/client/ui/tray_session.go @@ -177,22 +177,16 @@ func (t *Tray) registerSessionWarningCategory() { }); err != nil { log.Debugf("register session-warning notification category: %v", err) } - t.svc.Notifier.OnNotificationResponse(func(result notifications.NotificationResult) { - if result.Error != nil { - log.Debugf("notification response error: %v", result.Error) - return - } - if result.Response.CategoryID != notifyCategorySessionWarning { - return - } - switch result.Response.ActionIdentifier { - case notifyActionExtendNow, notifications.DefaultActionIdentifier: - // DefaultActionIdentifier is the body-click on platforms with no separate buttons; treat as Extend. - go t.runExtendSession() - case notifyActionDismiss: - go t.dismissSessionWarning() - } - }) +} + +func (t *Tray) handleSessionWarningResponse(resp notifications.NotificationResponse) { + switch resp.ActionIdentifier { + case notifyActionExtendNow, notifications.DefaultActionIdentifier: + // DefaultActionIdentifier is the body-click on platforms with no separate buttons; treat as Extend. + go t.runExtendSession() + case notifyActionDismiss: + go t.dismissSessionWarning() + } } // buildSessionWarningBody composes the localised notification body from the daemon's metadata. diff --git a/shared/signal/client/client.go b/shared/signal/client/client.go index fb77cb90f..efd2e7e27 100644 --- a/shared/signal/client/client.go +++ b/shared/signal/client/client.go @@ -49,6 +49,7 @@ type Credential struct { type CredentialPayload struct { Type proto.Body_Type WgListenPort int + FiledropPort uint16 Credential *Credential RosenpassPubKey []byte RosenpassAddr string @@ -76,6 +77,7 @@ func MarshalCredential(myKey wgtypes.Key, remoteKey string, p CredentialPayload) Type: p.Type, Payload: fmt.Sprintf("%s:%s", p.Credential.UFrag, p.Credential.Pwd), WgListenPort: uint32(p.WgListenPort), + FiledropPort: uint32(p.FiledropPort), NetBirdVersion: version.NetbirdVersion(), RosenpassConfig: &proto.RosenpassConfig{ RosenpassPubKey: p.RosenpassPubKey, diff --git a/shared/signal/proto/signalexchange.pb.go b/shared/signal/proto/signalexchange.pb.go index 8e07977f0..c4bc0f645 100644 --- a/shared/signal/proto/signalexchange.pb.go +++ b/shared/signal/proto/signalexchange.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.26.0 -// protoc v3.21.12 +// protoc v7.34.1 // source: signalexchange.proto package proto @@ -239,6 +239,9 @@ type Body struct { // fallback dial target when DNS resolution of relayServerAddress fails. // SNI/TLS verification still uses relayServerAddress. RelayServerIP []byte `protobuf:"bytes,11,opt,name=relayServerIP,proto3,oneof" json:"relayServerIP,omitempty"` + // filedropPort is the sender's file drop listen port when it differs from + // the well-known default; 0 (absent) means the default port. + FiledropPort uint32 `protobuf:"varint,12,opt,name=filedropPort,proto3" json:"filedropPort,omitempty"` } func (x *Body) Reset() { @@ -343,6 +346,13 @@ func (x *Body) GetRelayServerIP() []byte { return nil } +func (x *Body) GetFiledropPort() uint32 { + if x != nil { + return x.FiledropPort + } + return 0 +} + // Mode indicates a connection mode type Mode struct { state protoimpl.MessageState @@ -466,7 +476,7 @@ var file_signalexchange_proto_rawDesc = []byte{ 0x52, 0x09, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x52, - 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xd2, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d, + 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xf6, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, @@ -494,39 +504,41 @@ var file_signalexchange_proto_rawDesc = []byte{ 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x02, 0x52, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70, - 0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, - 0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x44, - 0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, 0x4f, 0x44, 0x45, 0x10, - 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x12, 0x0d, - 0x0a, 0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x06, 0x42, 0x15, 0x0a, - 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, - 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, - 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, 0x6f, - 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, 0x42, - 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, 0x6f, - 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a, - 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, - 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, - 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, - 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69, - 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04, - 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, - 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, - 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, - 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f, - 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69, - 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, - 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, - 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, - 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, - 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x66, 0x69, 0x6c, + 0x65, 0x64, 0x72, 0x6f, 0x70, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x0c, 0x66, 0x69, 0x6c, 0x65, 0x64, 0x72, 0x6f, 0x70, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x52, 0x0a, + 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, + 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, + 0x43, 0x41, 0x4e, 0x44, 0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, + 0x4f, 0x44, 0x45, 0x10, 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, + 0x10, 0x05, 0x12, 0x0d, 0x0a, 0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, + 0x06, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, + 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, + 0x0a, 0x04, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, + 0x0a, 0x0f, 0x52, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x12, 0x28, 0x0a, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, + 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, + 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, + 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, + 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, + 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, + 0x0a, 0x0e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, + 0x12, 0x4c, 0x0a, 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, + 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, + 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, + 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, + 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, + 0x0a, 0x0d, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, + 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, + 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, + 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/shared/signal/proto/signalexchange.proto b/shared/signal/proto/signalexchange.proto index 8c304e37c..8a865a073 100644 --- a/shared/signal/proto/signalexchange.proto +++ b/shared/signal/proto/signalexchange.proto @@ -75,6 +75,10 @@ message Body { // fallback dial target when DNS resolution of relayServerAddress fails. // SNI/TLS verification still uses relayServerAddress. optional bytes relayServerIP = 11; + + // filedropPort is the sender's file drop listen port when it differs from + // the well-known default; 0 (absent) means the default port. + uint32 filedropPort = 12; } // Mode indicates a connection mode