mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-17 13:11:30 +02:00
Compare commits
21 Commits
feature/an
...
android-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faf8619a83 | ||
|
|
eba86a3007 | ||
|
|
3d7b25a02c | ||
|
|
73cffdb702 | ||
|
|
cfd3a231e2 | ||
|
|
31e558a64d | ||
|
|
f986b33b92 | ||
|
|
fc52cdcedd | ||
|
|
b4cc8a16d9 | ||
|
|
3c973c98e3 | ||
|
|
1fcb6d4bed | ||
|
|
7612b4d299 | ||
|
|
ecce1aa2a1 | ||
|
|
53f2518af1 | ||
|
|
71a4276426 | ||
|
|
71bfc73cd1 | ||
|
|
77e7d82d5a | ||
|
|
e3e5a25605 | ||
|
|
072fa8143b | ||
|
|
8fb3e707af | ||
|
|
902263ac96 |
@@ -26,6 +26,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -40,11 +42,6 @@ const (
|
||||
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile
|
||||
type ConnectionListener interface {
|
||||
peer.Listener
|
||||
}
|
||||
|
||||
// TunAdapter export internal TunAdapter for mobile
|
||||
type TunAdapter interface {
|
||||
device.TunAdapter
|
||||
@@ -85,6 +82,13 @@ type Client struct {
|
||||
deviceName string
|
||||
uiVersion string
|
||||
networkChangeListener listener.NetworkChangeListener
|
||||
// netState outlives engine restarts: it mirrors the OS connectivity, not
|
||||
// the engine lifecycle. Run and RunWithoutLogin inject it into each new
|
||||
// ConnectClient, which distributes it to every reconnection loop.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
|
||||
sweeper *netsweep.Sweeper
|
||||
|
||||
stateMu sync.RWMutex
|
||||
connectClient *internal.ConnectClient
|
||||
@@ -110,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) {
|
||||
@@ -156,6 +165,8 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
|
||||
recorder: peer.NewRecorder(""),
|
||||
ctxCancelLock: &sync.Mutex{},
|
||||
networkChangeListener: networkChangeListener,
|
||||
netState: netstate.New(),
|
||||
sweeper: netsweep.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +207,9 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
}
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
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
|
||||
@@ -237,7 +250,9 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
||||
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
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)
|
||||
}
|
||||
@@ -285,6 +300,24 @@ func (c *Client) GetTunSettings() (*TunSettings, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetNetworkAvailable feeds OS-reported network availability into the client.
|
||||
// While unavailable, the internal reconnect loops suspend their attempts and
|
||||
// the connection listener reports NoNetwork instead of Connecting; when
|
||||
// availability returns, the loops resume immediately with a fresh backoff.
|
||||
func (c *Client) SetNetworkAvailable(available bool) {
|
||||
c.netState.Set(available)
|
||||
c.recorder.SetNetworkAvailable(available)
|
||||
}
|
||||
|
||||
// NotifyNetworkChange marks the management, signal and relay connections
|
||||
// stale after the OS switched networks and schedules a sweep that cuts
|
||||
// whatever has not redialed on the new network by then. The engine and the
|
||||
// TUN device stay untouched.
|
||||
func (c *Client) NotifyNetworkChange() {
|
||||
c.sweeper.MarkNetworkChange()
|
||||
log.Infof("network change: connections marked stale")
|
||||
}
|
||||
|
||||
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||
// It works both with and without a running engine. anonymizeLevel is "default"
|
||||
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
|
||||
@@ -525,7 +558,11 @@ func (c *Client) OnUpdatedHostDNS(list *DNSList) error {
|
||||
|
||||
// SetConnectionListener set the network connection listener
|
||||
func (c *Client) SetConnectionListener(listener ConnectionListener) {
|
||||
c.recorder.SetConnectionListener(listener)
|
||||
if listener == nil {
|
||||
c.recorder.RemoveConnectionListener()
|
||||
return
|
||||
}
|
||||
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
|
||||
}
|
||||
|
||||
// RemoveConnectionListener remove connection listener
|
||||
|
||||
78
client/android/client_filedrop.go
Normal file
78
client/android/client_filedrop.go
Normal file
@@ -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)
|
||||
}
|
||||
41
client/android/connection_listener.go
Normal file
41
client/android/connection_listener.go
Normal file
@@ -0,0 +1,41 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// Client state values delivered via ConnectionListener.OnStateChanged,
|
||||
// re-exported as basic constants so gomobile emits them into the generated
|
||||
// Java bindings. They mirror peer.ClientState*: append-only, never reorder.
|
||||
const (
|
||||
ClientStateDisconnected = int(peer.ClientStateDisconnected)
|
||||
ClientStateConnected = int(peer.ClientStateConnected)
|
||||
ClientStateConnecting = int(peer.ClientStateConnecting)
|
||||
ClientStateDisconnecting = int(peer.ClientStateDisconnecting)
|
||||
ClientStateNoNetwork = int(peer.ClientStateNoNetwork)
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile. It mirrors
|
||||
// peer.Listener with OnStateChanged taking a plain int (one of the
|
||||
// ClientState* constants), because gomobile cannot bind named types.
|
||||
type ConnectionListener interface {
|
||||
OnStateChanged(state int)
|
||||
OnConnected()
|
||||
OnDisconnected()
|
||||
OnConnecting()
|
||||
OnDisconnecting()
|
||||
OnAddressChanged(string, string)
|
||||
OnPeersListChanged(int)
|
||||
}
|
||||
|
||||
// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to
|
||||
// peer.Listener, converting the typed state to the int the binding carries.
|
||||
type connectionListenerAdapter struct {
|
||||
ConnectionListener
|
||||
}
|
||||
|
||||
func (a connectionListenerAdapter) OnStateChanged(state peer.ClientState) {
|
||||
a.ConnectionListener.OnStateChanged(int(state))
|
||||
}
|
||||
205
client/android/filedrop.go
Normal file
205
client/android/filedrop.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
122
client/android/filedrop_payload.go
Normal file
122
client/android/filedrop_payload.go
Normal file
@@ -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()
|
||||
}
|
||||
261
client/android/filedrop_test.go
Normal file
261
client/android/filedrop_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
163
client/android/filedrop_transfer.go
Normal file
163
client/android/filedrop_transfer.go
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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: <dir>/netbird.cfg is the default profile, while
|
||||
// <dir>/profiles/<id>.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.
|
||||
|
||||
@@ -16,28 +16,47 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
)
|
||||
|
||||
func WithCustomDialer(_ bool, _ string) grpc.DialOption {
|
||||
return grpc.WithContextDialer(dialContext)
|
||||
}
|
||||
|
||||
// WithSweeper dials like WithCustomDialer but registers connections and
|
||||
// dials with the sweeper. Append it after WithCustomDialer: gRPC applies
|
||||
// dial options in order, so the later context dialer wins.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) grpc.DialOption {
|
||||
return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
if runtime.GOOS == "linux" {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
|
||||
}
|
||||
dial := sweeper.StartDial(ctx)
|
||||
defer dial.Release()
|
||||
|
||||
// the custom dialer requires root permissions which are not required for use cases run as non-root
|
||||
if currentUser.Uid != "0" {
|
||||
log.Debug("Not running as root, using standard dialer")
|
||||
dialer := &net.Dialer{}
|
||||
return dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
|
||||
conn, err := dialContext(dial.Ctx(), addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
return dial.WrapConn(conn)
|
||||
})
|
||||
}
|
||||
|
||||
func dialContext(ctx context.Context, addr string) (net.Conn, error) {
|
||||
if runtime.GOOS == "linux" {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
|
||||
}
|
||||
|
||||
// the custom dialer requires root permissions which are not required for use cases run as non-root
|
||||
if currentUser.Uid != "0" {
|
||||
log.Debug("Not running as root, using standard dialer")
|
||||
dialer := &net.Dialer{}
|
||||
return dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package grpc
|
||||
import (
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/util/wsproxy/client"
|
||||
)
|
||||
|
||||
@@ -11,3 +12,8 @@ import (
|
||||
func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption {
|
||||
return client.WithWebSocketDialer(tlsEnabled, component)
|
||||
}
|
||||
|
||||
// WithSweeper is a no-op on WASM/JS: there is no network change signal.
|
||||
func WithSweeper(_ *netsweep.Sweeper) grpc.DialOption {
|
||||
return grpc.EmptyDialOption{}
|
||||
}
|
||||
|
||||
49
client/grpc/retry.go
Normal file
49
client/grpc/retry.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
// Retry mirrors backoff.Retry, but the sleep between attempts also wakes on
|
||||
// OS network availability transitions: an operation cut down by a network
|
||||
// change retries the moment the network settles instead of sleeping through
|
||||
// the recovery. A nil netState never fires, leaving plain backoff.Retry
|
||||
// behavior.
|
||||
func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, netState *netstate.State) error {
|
||||
bo.Reset()
|
||||
for {
|
||||
err := operation()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var permanent *backoff.PermanentError
|
||||
if errors.As(err, &permanent) {
|
||||
return permanent.Err
|
||||
}
|
||||
|
||||
next := bo.NextBackOff()
|
||||
if next == backoff.Stop {
|
||||
if cerr := ctx.Err(); cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
timer := time.NewTimer(next)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-netState.Changed():
|
||||
timer.Stop()
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
91
client/grpc/retry_test.go
Normal file
91
client/grpc/retry_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
func TestRetryWakesOnNetworkChange(t *testing.T) {
|
||||
ns := netstate.New()
|
||||
attempts := 0
|
||||
operation := func() error {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return errors.New("cut by network change")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
ns.Set(false)
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Minute), ns)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, attempts)
|
||||
assert.Less(t, time.Since(start), time.Second, "the transition must cut the minute-long sleep short")
|
||||
}
|
||||
|
||||
func TestRetryPermanentError(t *testing.T) {
|
||||
sentinel := errors.New("permission denied")
|
||||
operation := func() error {
|
||||
return backoff.Permanent(sentinel)
|
||||
}
|
||||
|
||||
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil)
|
||||
assert.ErrorIs(t, err, sentinel)
|
||||
}
|
||||
|
||||
func TestRetryNilNetState(t *testing.T) {
|
||||
attempts := 0
|
||||
operation := func() error {
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
return errors.New("transient")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, attempts)
|
||||
}
|
||||
|
||||
func TestRetryStops(t *testing.T) {
|
||||
failure := errors.New("still failing")
|
||||
operation := func() error {
|
||||
return failure
|
||||
}
|
||||
|
||||
err := Retry(context.Background(), operation, &backoff.StopBackOff{}, nil)
|
||||
assert.ErrorIs(t, err, failure)
|
||||
}
|
||||
|
||||
func TestRetryCtxCancelDuringSleep(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
operation := func() error {
|
||||
return errors.New("failing")
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
err := Retry(ctx, operation, backoff.NewConstantBackOff(time.Minute), netstate.New())
|
||||
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
assert.Less(t, time.Since(start), time.Second)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -38,6 +39,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ssh"
|
||||
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
|
||||
@@ -64,24 +67,49 @@ 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
|
||||
|
||||
// netState gates every reconnection loop on OS-reported network
|
||||
// availability. Nil (the default) disables gating; mobile platforms
|
||||
// inject it via WithNetworkState.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper cuts the management, signal and relay connections on network
|
||||
// change; nil disables it.
|
||||
sweeper *netsweep.Sweeper
|
||||
}
|
||||
|
||||
// ConnectClientOption configures optional ConnectClient behavior.
|
||||
type ConnectClientOption func(*ConnectClient)
|
||||
|
||||
// WithNetworkState injects the OS network availability state that gates every
|
||||
// reconnection loop; without it gating is disabled.
|
||||
func WithNetworkState(netState *netstate.State) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.netState = netState }
|
||||
}
|
||||
|
||||
// WithSweeper injects the network change sweeper.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.sweeper = sweeper }
|
||||
}
|
||||
|
||||
func NewConnectClient(
|
||||
ctx context.Context,
|
||||
config *profilemanager.Config,
|
||||
statusRecorder *peer.Status,
|
||||
opts ...ConnectClientOption,
|
||||
) *ConnectClient {
|
||||
// Derive the run context here so Stop owns the cancel that unblocks the run
|
||||
// loop. runCancel is set once at construction, so Stop can call it without
|
||||
// racing the run loop's startup. Callers therefore need not cancel before Stop.
|
||||
runCtx, runCancel := context.WithCancel(ctx)
|
||||
return &ConnectClient{
|
||||
c := &ConnectClient{
|
||||
ctx: runCtx,
|
||||
runCancel: runCancel,
|
||||
runExited: make(chan struct{}),
|
||||
@@ -89,12 +117,22 @@ func NewConnectClient(
|
||||
statusRecorder: statusRecorder,
|
||||
engineMutex: sync.Mutex{},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -274,6 +312,13 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
return nil
|
||||
}
|
||||
|
||||
// suspend connection attempts while the OS reports no usable network
|
||||
if waited, err := c.netState.Wait(c.ctx); err != nil {
|
||||
return nil
|
||||
} else if waited {
|
||||
backOff.Reset()
|
||||
}
|
||||
|
||||
state.Set(StatusConnecting)
|
||||
|
||||
engineCtx, cancel := context.WithCancel(c.ctx)
|
||||
@@ -285,7 +330,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
}()
|
||||
|
||||
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled,
|
||||
mgm.WithNetworkState(c.netState), mgm.WithSweeper(c.sweeper))
|
||||
if err != nil {
|
||||
// On daemon shutdown / Down() the parent context is cancelled
|
||||
// and the dial fails with "context canceled". Wrapping that
|
||||
@@ -360,7 +406,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
}()
|
||||
|
||||
// with the global Netbird config in hand connect (just a connection, no stream yet) Signal
|
||||
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey)
|
||||
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netState, c.sweeper)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return wrapErr(err)
|
||||
@@ -396,7 +442,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
engineConfig.StateDir = filepath.Dir(path)
|
||||
}
|
||||
|
||||
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
|
||||
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU,
|
||||
relayClient.WithNetworkState(c.netState), relayClient.WithSweeper(c.sweeper))
|
||||
c.statusRecorder.SetRelayMgr(relayManager)
|
||||
if len(relayURLs) > 0 {
|
||||
if token != nil {
|
||||
@@ -424,6 +471,8 @@ 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)
|
||||
c.engine = engine
|
||||
@@ -480,6 +529,16 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
// status stream stuck at Connecting.
|
||||
err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
|
||||
if err != nil {
|
||||
// Once the client context is cancelled backoff.WithContext surfaces the
|
||||
// bare context error, and any attempt torn down mid-flight reports the
|
||||
// same. That cancellation is the caller asking us to stop (Stop, Down or
|
||||
// an engine restart), so exit cleanly instead of handing back a failure
|
||||
// the caller would have to distinguish from a real one.
|
||||
if c.ctx.Err() != nil && errors.Is(err, context.Canceled) {
|
||||
log.Info("exiting client retry loop, context cancelled")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
|
||||
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
|
||||
state.Set(StatusNeedsLogin)
|
||||
@@ -673,7 +732,7 @@ func selectMTU(localMTU uint16, peerMTU int32) uint16 {
|
||||
}
|
||||
|
||||
// connectToSignal creates Signal Service client and established a connection
|
||||
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key) (*signal.GrpcClient, error) {
|
||||
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netState *netstate.State, sweeper *netsweep.Sweeper) (*signal.GrpcClient, error) {
|
||||
var sigTLSEnabled bool
|
||||
if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS {
|
||||
sigTLSEnabled = true
|
||||
@@ -681,7 +740,8 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP
|
||||
sigTLSEnabled = false
|
||||
}
|
||||
|
||||
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled)
|
||||
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled,
|
||||
signal.WithNetworkState(netState), signal.WithSweeper(sweeper))
|
||||
if err != nil {
|
||||
log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err)
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err)
|
||||
|
||||
@@ -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"
|
||||
@@ -59,6 +60,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/syncstore"
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/jobexec"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
@@ -181,6 +183,10 @@ 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
|
||||
}
|
||||
|
||||
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
|
||||
@@ -204,6 +210,10 @@ type Engine struct {
|
||||
config *EngineConfig
|
||||
mobileDep MobileDependency
|
||||
|
||||
// netState gates the peer reconnection guards on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
netState *netstate.State
|
||||
|
||||
// STUNs is a list of STUN servers used by ICE
|
||||
STUNs []*stun.URI
|
||||
// TURNs is a list of STUN servers used by ICE
|
||||
@@ -236,6 +246,10 @@ type Engine struct {
|
||||
|
||||
sshServer sshServer
|
||||
|
||||
fileDrop *filedrop.Manager
|
||||
fileDropRunning bool
|
||||
fileDropPort uint16
|
||||
|
||||
statusRecorder *peer.Status
|
||||
|
||||
firewall firewallManager.Manager
|
||||
@@ -337,6 +351,7 @@ func NewEngine(
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
config: config,
|
||||
mobileDep: mobileDep,
|
||||
netState: services.NetState,
|
||||
STUNs: []*stun.URI{},
|
||||
TURNs: []*stun.URI{},
|
||||
networkSerial: 0,
|
||||
@@ -350,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
|
||||
@@ -415,6 +431,8 @@ func (e *Engine) stopLocked() {
|
||||
log.Warnf("failed to stop SSH server: %v", err)
|
||||
}
|
||||
|
||||
e.stopFileDrop()
|
||||
|
||||
e.cleanupSSHConfig()
|
||||
|
||||
if e.ingressGatewayMgr != nil {
|
||||
@@ -1293,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()
|
||||
@@ -1893,7 +1913,8 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
|
||||
Addr: e.getRosenpassAddr(),
|
||||
PermissiveMode: e.config.RosenpassPermissive,
|
||||
},
|
||||
ICEConfig: e.createICEConfig(),
|
||||
ICEConfig: e.createICEConfig(),
|
||||
NetworkState: e.netState,
|
||||
}
|
||||
|
||||
serviceDependencies := peer.ServiceDependencies{
|
||||
@@ -1960,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 {
|
||||
@@ -2439,6 +2462,8 @@ func (e *Engine) GetWgV6Addr() netip.Addr {
|
||||
return e.wgInterface.Address().IPv6
|
||||
}
|
||||
|
||||
// RenewTun swaps the tunnel device for the one behind fd, which the platform
|
||||
// hands over whenever it re-establishes the interface.
|
||||
func (e *Engine) RenewTun(fd int) error {
|
||||
e.syncMsgMux.Lock()
|
||||
wgInterface := e.wgInterface
|
||||
@@ -2448,7 +2473,12 @@ func (e *Engine) RenewTun(fd int) error {
|
||||
return fmt.Errorf("wireguard interface not initialized")
|
||||
}
|
||||
|
||||
return wgInterface.RenewTun(fd)
|
||||
if err := wgInterface.RenewTun(fd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.restartFileDrop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag
|
||||
|
||||
143
client/internal/engine_filedrop.go
Normal file
143
client/internal/engine_filedrop.go
Normal file
@@ -0,0 +1,143 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// restartFileDrop rebinds the receiver after the platform replaced the tunnel
|
||||
// device. The listeners are bound to the overlay address of the interface being
|
||||
// swapped out and do not survive it: Android renews the tun on every route
|
||||
// change, which leaves the IPv4 listener dead with accept4: invalid argument.
|
||||
func (e *Engine) restartFileDrop() {
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
|
||||
if e.fileDrop == nil || !e.fileDropRunning || e.wgInterface == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.stopFileDrop()
|
||||
e.startFileDrop()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
446
client/internal/filedrop/client.go
Normal file
446
client/internal/filedrop/client.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
100
client/internal/filedrop/delivery.go
Normal file
100
client/internal/filedrop/delivery.go
Normal file
@@ -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
|
||||
}
|
||||
7
client/internal/filedrop/delivery_other.go
Normal file
7
client/internal/filedrop/delivery_other.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build windows || js
|
||||
|
||||
package filedrop
|
||||
|
||||
func chownToDirOwner(string, string) error {
|
||||
return nil
|
||||
}
|
||||
29
client/internal/filedrop/delivery_unix.go
Normal file
29
client/internal/filedrop/delivery_unix.go
Normal file
@@ -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
|
||||
}
|
||||
850
client/internal/filedrop/filedrop_test.go
Normal file
850
client/internal/filedrop/filedrop_test.go
Normal file
@@ -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)
|
||||
}
|
||||
205
client/internal/filedrop/history.go
Normal file
205
client/internal/filedrop/history.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
221
client/internal/filedrop/http.go
Normal file
221
client/internal/filedrop/http.go
Normal file
@@ -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})
|
||||
}
|
||||
660
client/internal/filedrop/manager.go
Normal file
660
client/internal/filedrop/manager.go
Normal file
@@ -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)
|
||||
}
|
||||
282
client/internal/filedrop/offer.go
Normal file
282
client/internal/filedrop/offer.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
206
client/internal/filedrop/policy.go
Normal file
206
client/internal/filedrop/policy.go
Normal file
@@ -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{},
|
||||
}
|
||||
}
|
||||
81
client/internal/filedrop/ports.go
Normal file
81
client/internal/filedrop/ports.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
197
client/internal/filedrop/protocol.go
Normal file
197
client/internal/filedrop/protocol.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
226
client/internal/filedrop/receiver.go
Normal file
226
client/internal/filedrop/receiver.go
Normal file
@@ -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
|
||||
}
|
||||
262
client/internal/filedrop/server.go
Normal file
262
client/internal/filedrop/server.go
Normal file
@@ -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
|
||||
}
|
||||
131
client/internal/filedrop/spool.go
Normal file
131
client/internal/filedrop/spool.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
client/internal/filedrop/store.go
Normal file
58
client/internal/filedrop/store.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/portforward"
|
||||
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||
)
|
||||
@@ -93,6 +94,10 @@ type ConnConfig struct {
|
||||
|
||||
// ICEConfig ICE protocol configuration
|
||||
ICEConfig icemaker.Config
|
||||
|
||||
// NetworkState gates the reconnection guard on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
NetworkState *netstate.State
|
||||
}
|
||||
|
||||
type Conn struct {
|
||||
@@ -254,7 +259,7 @@ func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error {
|
||||
conn.handshaker.AddICEListener(conn.workerICE.OnNewOffer)
|
||||
}
|
||||
|
||||
conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher)
|
||||
conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher, conn.config.NetworkState)
|
||||
|
||||
conn.wg.Add(1)
|
||||
go func() {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
// ConnStatus represents the connection state as seen by the guard.
|
||||
@@ -31,20 +33,26 @@ type connStatusFunc func() ConnStatus
|
||||
// - Relayed connection disconnected
|
||||
// - ICE candidate changes
|
||||
type Guard struct {
|
||||
log *log.Entry
|
||||
isConnectedOnAllWay connStatusFunc
|
||||
timeout time.Duration
|
||||
srWatcher *SRWatcher
|
||||
log *log.Entry
|
||||
isConnectedOnAllWay connStatusFunc
|
||||
timeout time.Duration
|
||||
srWatcher *SRWatcher
|
||||
// netState gates reconnect attempts on OS-reported network availability;
|
||||
// nil disables gating.
|
||||
netState *netstate.State
|
||||
relayedConnDisconnected chan struct{}
|
||||
iCEConnDisconnected chan struct{}
|
||||
}
|
||||
|
||||
func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard {
|
||||
// NewGuard creates a reconnection guard for a peer connection. A nil netState
|
||||
// disables network availability gating.
|
||||
func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netState *netstate.State) *Guard {
|
||||
return &Guard{
|
||||
log: log,
|
||||
isConnectedOnAllWay: isConnectedFn,
|
||||
timeout: timeout,
|
||||
srWatcher: srWatcher,
|
||||
netState: netState,
|
||||
relayedConnDisconnected: make(chan struct{}, 1),
|
||||
iCEConnDisconnected: make(chan struct{}, 1),
|
||||
}
|
||||
@@ -96,9 +104,16 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
|
||||
iceState := &iceRetryState{log: g.log}
|
||||
defer iceState.reset()
|
||||
|
||||
netChanged := g.netState.Changed()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-tickerChannel:
|
||||
// skip attempts while the OS reports no usable network; the
|
||||
// netChanged case below resumes the loop once it returns
|
||||
if !g.netState.IsOnline() {
|
||||
continue
|
||||
}
|
||||
switch g.isConnectedOnAllWay() {
|
||||
case ConnStatusConnected:
|
||||
// all good, nothing to do
|
||||
@@ -135,6 +150,23 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
|
||||
tickerChannel = ticker.C
|
||||
iceState.reset()
|
||||
|
||||
case <-netChanged:
|
||||
// Re-arm for the next transition before acting on this one.
|
||||
netChanged = g.netState.Changed()
|
||||
if !g.netState.IsOnline() {
|
||||
continue
|
||||
}
|
||||
// Ticks skipped while offline drove the backoff towards its
|
||||
// maximum without ever attempting, and left the ICE budget
|
||||
// frozen — possibly in hourly mode. Recover on our own so the
|
||||
// peer does not depend on a signal or relay event that never
|
||||
// comes when both stayed up across the outage.
|
||||
g.log.Debugf("network is back, reset reconnection ticker")
|
||||
ticker.Stop()
|
||||
ticker = g.newReconnectTicker(ctx)
|
||||
tickerChannel = ticker.C
|
||||
iceState.reset()
|
||||
|
||||
case <-ctx.Done():
|
||||
g.log.Debugf("context is done, stop reconnect loop")
|
||||
return
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
func newTestGuard(status connStatusFunc) *Guard {
|
||||
srw := NewSRWatcher(nil, nil, nil, ice.Config{})
|
||||
return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw)
|
||||
return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw, nil)
|
||||
}
|
||||
|
||||
// countBackoffTickerGoroutines returns how many goroutines are currently sitting
|
||||
|
||||
107
client/internal/peer/guard/guard_netstate_test.go
Normal file
107
client/internal/peer/guard/guard_netstate_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package guard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
// newTestGuardWithNetState builds a guard with a realistic MaxInterval: the
|
||||
// backoff must be able to grow well past the outage, as it does in production
|
||||
// where the timeout is seconds to minutes.
|
||||
func newTestGuardWithNetState(status connStatusFunc, netState *netstate.State) *Guard {
|
||||
srw := NewSRWatcher(nil, nil, nil, ice.Config{})
|
||||
return NewGuard(log.WithField("test", "guard"), status, 30*time.Second, srw, netState)
|
||||
}
|
||||
|
||||
// TestGuard_RecoversAfterOfflineToOnline covers a peer that stays disconnected
|
||||
// across a network outage while neither signal nor relay reports an event —
|
||||
// both stayed up, as on a short airplane mode toggle over Wi-Fi.
|
||||
//
|
||||
// Every tick taken while offline is skipped, but it still advances the
|
||||
// exponential backoff, so by the time the network returns the next tick can be
|
||||
// tens of seconds away. Without an explicit reaction to the transition the
|
||||
// peer waits out that interval for a recovery that could start immediately.
|
||||
func TestGuard_RecoversAfterOfflineToOnline(t *testing.T) {
|
||||
netState := netstate.New()
|
||||
|
||||
var attempts atomic.Int32
|
||||
g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// Start from the reconnect ticker (800ms initial interval), the state a
|
||||
// peer is in after it loses its connection.
|
||||
go g.Start(ctx, func() { attempts.Add(1) })
|
||||
g.SetRelayedConnDisconnected()
|
||||
|
||||
// Let the backoff climb: 0.8s, 1.6s, 3.2s, 6.4s ... every tick is skipped
|
||||
// while offline, but each one doubles the wait for the next.
|
||||
netState.Set(false)
|
||||
time.Sleep(8 * time.Second)
|
||||
|
||||
offlineAttempts := attempts.Load()
|
||||
if offlineAttempts != 0 {
|
||||
t.Fatalf("callback ran %d times while offline, want 0", offlineAttempts)
|
||||
}
|
||||
|
||||
netState.Set(true)
|
||||
|
||||
// The next organic tick is now several seconds out, so anything within
|
||||
// this window can only come from reacting to the transition itself.
|
||||
pollCtx, stopPolling := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer stopPolling()
|
||||
|
||||
select {
|
||||
case <-pollCtx.Done():
|
||||
t.Fatal("peer was not retried within 2s of the network coming back, " +
|
||||
"with neither a signal nor a relay event to fall back on")
|
||||
case <-pollUntil(pollCtx, func() bool { return attempts.Load() > 0 }):
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuard_OfflineTransitionDoesNotRetry checks the other direction: going
|
||||
// offline must not itself trigger an attempt.
|
||||
func TestGuard_OfflineTransitionDoesNotRetry(t *testing.T) {
|
||||
netState := netstate.New()
|
||||
|
||||
var attempts atomic.Int32
|
||||
g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go g.Start(ctx, func() { attempts.Add(1) })
|
||||
|
||||
netState.Set(false)
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
if got := attempts.Load(); got != 0 {
|
||||
t.Fatalf("callback ran %d times after going offline, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// pollUntil closes the returned channel once cond holds. It gives up when ctx
|
||||
// is done, so the polling goroutine never outlives the test that started it.
|
||||
func pollUntil(ctx context.Context, cond func() bool) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
if cond() {
|
||||
close(done)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
@@ -1,11 +1,40 @@
|
||||
package peer
|
||||
|
||||
// ClientState identifies the client connection state delivered via
|
||||
// Listener.OnStateChanged.
|
||||
type ClientState int
|
||||
|
||||
// Client states. The numeric values cross the gomobile boundary (the mobile
|
||||
// bindings re-export them as integer constants), so they are a wire format:
|
||||
// append new states at the end, never reorder or insert.
|
||||
const (
|
||||
ClientStateDisconnected ClientState = iota
|
||||
ClientStateConnected
|
||||
ClientStateConnecting
|
||||
ClientStateDisconnecting
|
||||
// ClientStateNoNetwork is an overlay state: it is never stored as the
|
||||
// last notification, only derived from ClientStateConnecting while the
|
||||
// OS reports no usable network (see notifier.effectiveState).
|
||||
ClientStateNoNetwork
|
||||
)
|
||||
|
||||
// Listener is a callback type about the NetBird network connection state
|
||||
type Listener interface {
|
||||
// OnStateChanged reports every client state transition. New states are
|
||||
// delivered only through this callback; the per-state callbacks below
|
||||
// are kept for compatibility and will be removed once all consumers
|
||||
// have migrated.
|
||||
OnStateChanged(state ClientState)
|
||||
|
||||
// Deprecated: consume OnStateChanged instead.
|
||||
OnConnected()
|
||||
// Deprecated: consume OnStateChanged instead.
|
||||
OnDisconnected()
|
||||
// Deprecated: consume OnStateChanged instead.
|
||||
OnConnecting()
|
||||
// Deprecated: consume OnStateChanged instead.
|
||||
OnDisconnecting()
|
||||
|
||||
OnAddressChanged(string, string)
|
||||
OnPeersListChanged(int)
|
||||
}
|
||||
|
||||
@@ -4,31 +4,64 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
stateDisconnected = iota
|
||||
stateConnected
|
||||
stateConnecting
|
||||
stateDisconnecting
|
||||
)
|
||||
|
||||
type notifier struct {
|
||||
// publishLock orders state publication: it is held across computing the
|
||||
// effective state and handing it to the listener, so a transition cannot
|
||||
// overtake a newer one and leave the listener on a stale state.
|
||||
publishLock sync.Mutex
|
||||
serverStateLock sync.Mutex
|
||||
listenersLock sync.Mutex
|
||||
listener Listener
|
||||
currentClientState bool
|
||||
lastNotification int
|
||||
lastNotification ClientState
|
||||
lastNumberOfPeers int
|
||||
lastFqdnAddress string
|
||||
lastIPAddress string
|
||||
networkAvailable bool
|
||||
}
|
||||
|
||||
func newNotifier() *notifier {
|
||||
return ¬ifier{}
|
||||
return ¬ifier{
|
||||
networkAvailable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveState maps the computed state to what listeners should see:
|
||||
// while the OS reports no usable network, "Connecting" would be a lie —
|
||||
// connection attempts are suspended — so it is reported as NoNetwork.
|
||||
// Caller must hold serverStateLock.
|
||||
func (n *notifier) effectiveState(state ClientState) ClientState {
|
||||
if !n.networkAvailable && state == ClientStateConnecting {
|
||||
return ClientStateNoNetwork
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// setNetworkAvailable records the OS network availability and re-notifies
|
||||
// the listener when the flag flips the effective state (Connecting <->
|
||||
// NoNetwork).
|
||||
func (n *notifier) setNetworkAvailable(available bool) {
|
||||
n.publishLock.Lock()
|
||||
defer n.publishLock.Unlock()
|
||||
|
||||
n.serverStateLock.Lock()
|
||||
if n.networkAvailable == available {
|
||||
n.serverStateLock.Unlock()
|
||||
return
|
||||
}
|
||||
previous := n.effectiveState(n.lastNotification)
|
||||
n.networkAvailable = available
|
||||
current := n.effectiveState(n.lastNotification)
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
if previous != current {
|
||||
n.notify(current)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) setListener(listener Listener) {
|
||||
n.serverStateLock.Lock()
|
||||
lastNotification := n.lastNotification
|
||||
lastNotification := n.effectiveState(n.lastNotification)
|
||||
numOfPeers := n.lastNumberOfPeers
|
||||
fqdnAddress := n.lastFqdnAddress
|
||||
address := n.lastIPAddress
|
||||
@@ -52,6 +85,9 @@ func (n *notifier) removeListener() {
|
||||
}
|
||||
|
||||
func (n *notifier) updateServerStates(mgmState bool, signalState bool) {
|
||||
n.publishLock.Lock()
|
||||
defer n.publishLock.Unlock()
|
||||
|
||||
n.serverStateLock.Lock()
|
||||
calculatedState := n.calculateState(mgmState, signalState)
|
||||
|
||||
@@ -61,43 +97,54 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) {
|
||||
}
|
||||
|
||||
n.lastNotification = calculatedState
|
||||
effective := n.effectiveState(calculatedState)
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
n.notify(calculatedState)
|
||||
n.notify(effective)
|
||||
}
|
||||
|
||||
func (n *notifier) clientStart() {
|
||||
n.publishLock.Lock()
|
||||
defer n.publishLock.Unlock()
|
||||
|
||||
n.serverStateLock.Lock()
|
||||
n.currentClientState = true
|
||||
n.lastNotification = stateConnecting
|
||||
n.lastNotification = ClientStateConnecting
|
||||
effective := n.effectiveState(ClientStateConnecting)
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
n.notify(stateConnecting)
|
||||
n.notify(effective)
|
||||
}
|
||||
|
||||
func (n *notifier) clientStop() {
|
||||
n.publishLock.Lock()
|
||||
defer n.publishLock.Unlock()
|
||||
|
||||
n.serverStateLock.Lock()
|
||||
n.currentClientState = false
|
||||
n.lastNotification = stateDisconnected
|
||||
n.lastNotification = ClientStateDisconnected
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
n.notify(stateDisconnected)
|
||||
n.notify(ClientStateDisconnected)
|
||||
}
|
||||
|
||||
func (n *notifier) clientTearDown() {
|
||||
n.publishLock.Lock()
|
||||
defer n.publishLock.Unlock()
|
||||
|
||||
n.serverStateLock.Lock()
|
||||
n.currentClientState = false
|
||||
n.lastNotification = stateDisconnecting
|
||||
n.lastNotification = ClientStateDisconnecting
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
n.notify(stateDisconnecting)
|
||||
n.notify(ClientStateDisconnecting)
|
||||
}
|
||||
|
||||
func (n *notifier) isServerStateChanged(newState int) bool {
|
||||
func (n *notifier) isServerStateChanged(newState ClientState) bool {
|
||||
return n.lastNotification != newState
|
||||
}
|
||||
|
||||
func (n *notifier) notify(state int) {
|
||||
func (n *notifier) notify(state ClientState) {
|
||||
n.listenersLock.Lock()
|
||||
listener := n.listener
|
||||
n.listenersLock.Unlock()
|
||||
@@ -109,20 +156,20 @@ func (n *notifier) notify(state int) {
|
||||
notifyListener(listener, state)
|
||||
}
|
||||
|
||||
func (n *notifier) calculateState(managementConn, signalConn bool) int {
|
||||
func (n *notifier) calculateState(managementConn, signalConn bool) ClientState {
|
||||
if managementConn && signalConn {
|
||||
return stateConnected
|
||||
return ClientStateConnected
|
||||
}
|
||||
|
||||
if !managementConn && !signalConn && !n.currentClientState {
|
||||
return stateDisconnected
|
||||
return ClientStateDisconnected
|
||||
}
|
||||
|
||||
if n.lastNotification == stateDisconnecting {
|
||||
return stateDisconnecting
|
||||
if n.lastNotification == ClientStateDisconnecting {
|
||||
return ClientStateDisconnecting
|
||||
}
|
||||
|
||||
return stateConnecting
|
||||
return ClientStateConnecting
|
||||
}
|
||||
|
||||
func (n *notifier) peerListChanged(numOfPeers int) {
|
||||
@@ -159,15 +206,19 @@ func (n *notifier) localAddressChanged(fqdn, address string) {
|
||||
listener.OnAddressChanged(fqdn, address)
|
||||
}
|
||||
|
||||
func notifyListener(l Listener, state int) {
|
||||
func notifyListener(l Listener, state ClientState) {
|
||||
// legacy per-state callbacks; NoNetwork is delivered only via
|
||||
// OnStateChanged below
|
||||
switch state {
|
||||
case stateDisconnected:
|
||||
case ClientStateDisconnected:
|
||||
l.OnDisconnected()
|
||||
case stateConnected:
|
||||
case ClientStateConnected:
|
||||
l.OnConnected()
|
||||
case stateConnecting:
|
||||
case ClientStateConnecting:
|
||||
l.OnConnecting()
|
||||
case stateDisconnecting:
|
||||
case ClientStateDisconnecting:
|
||||
l.OnDisconnecting()
|
||||
}
|
||||
|
||||
l.OnStateChanged(state)
|
||||
}
|
||||
|
||||
108
client/internal/peer/notifier_concurrent_test.go
Normal file
108
client/internal/peer/notifier_concurrent_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type recordingListener struct {
|
||||
mu sync.Mutex
|
||||
states []ClientState
|
||||
onState func(ClientState)
|
||||
}
|
||||
|
||||
func (l *recordingListener) OnStateChanged(state ClientState) {
|
||||
l.mu.Lock()
|
||||
l.states = append(l.states, state)
|
||||
hook := l.onState
|
||||
l.mu.Unlock()
|
||||
|
||||
if hook != nil {
|
||||
hook(state)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *recordingListener) last() (ClientState, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if len(l.states) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return l.states[len(l.states)-1], true
|
||||
}
|
||||
|
||||
func (l *recordingListener) snapshot() []ClientState {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return append([]ClientState(nil), l.states...)
|
||||
}
|
||||
|
||||
func (l *recordingListener) OnConnected() {}
|
||||
func (l *recordingListener) OnDisconnected() {}
|
||||
func (l *recordingListener) OnConnecting() {}
|
||||
func (l *recordingListener) OnDisconnecting() {}
|
||||
func (l *recordingListener) OnAddressChanged(string, string) {}
|
||||
func (l *recordingListener) OnPeersListChanged(int) {}
|
||||
|
||||
// TestNotifier_ConcurrentAvailabilityFlipOrdersPublication holds the first
|
||||
// transition inside the listener callback and flips availability again from
|
||||
// another goroutine while it is parked. The second flip must not publish
|
||||
// ahead of the one in flight, otherwise the listener ends up on a state the
|
||||
// notifier already superseded.
|
||||
func TestNotifier_ConcurrentAvailabilityFlipOrdersPublication(t *testing.T) {
|
||||
n := newNotifier()
|
||||
n.currentClientState = true
|
||||
n.lastNotification = ClientStateConnecting
|
||||
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
|
||||
l := &recordingListener{}
|
||||
l.onState = func(state ClientState) {
|
||||
if state != ClientStateNoNetwork {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.onState = nil
|
||||
l.mu.Unlock()
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
n.listener = l
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
n.setNetworkAvailable(false)
|
||||
}()
|
||||
|
||||
<-entered
|
||||
|
||||
flipped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(flipped)
|
||||
n.setNetworkAvailable(true)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-flipped:
|
||||
t.Fatal("the online transition published while the offline one was " +
|
||||
"still in flight; publication is not serialized")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(release)
|
||||
<-flipped
|
||||
wg.Wait()
|
||||
|
||||
got, ok := l.last()
|
||||
if !ok {
|
||||
t.Fatal("listener never observed a state")
|
||||
}
|
||||
if got != ClientStateConnecting {
|
||||
t.Fatalf("listener holds %v after the network came back, want Connecting; sequence: %v",
|
||||
got, l.snapshot())
|
||||
}
|
||||
}
|
||||
@@ -6,29 +6,32 @@ import (
|
||||
)
|
||||
|
||||
type mocListener struct {
|
||||
lastState int
|
||||
lastState ClientState
|
||||
wg sync.WaitGroup
|
||||
peersWg sync.WaitGroup
|
||||
peers int
|
||||
}
|
||||
|
||||
func (l *mocListener) OnConnected() {
|
||||
l.lastState = stateConnected
|
||||
l.lastState = ClientStateConnected
|
||||
l.wg.Done()
|
||||
}
|
||||
func (l *mocListener) OnDisconnected() {
|
||||
l.lastState = stateDisconnected
|
||||
l.lastState = ClientStateDisconnected
|
||||
l.wg.Done()
|
||||
}
|
||||
func (l *mocListener) OnConnecting() {
|
||||
l.lastState = stateConnecting
|
||||
l.lastState = ClientStateConnecting
|
||||
l.wg.Done()
|
||||
}
|
||||
func (l *mocListener) OnDisconnecting() {
|
||||
l.lastState = stateDisconnecting
|
||||
l.lastState = ClientStateDisconnecting
|
||||
l.wg.Done()
|
||||
}
|
||||
|
||||
func (l *mocListener) OnStateChanged(state ClientState) {
|
||||
|
||||
}
|
||||
func (l *mocListener) OnAddressChanged(host, addr string) {
|
||||
|
||||
}
|
||||
@@ -57,15 +60,15 @@ func Test_notifier_serverState(t *testing.T) {
|
||||
|
||||
type scenario struct {
|
||||
name string
|
||||
expected int
|
||||
expected ClientState
|
||||
mgmState bool
|
||||
signalState bool
|
||||
}
|
||||
scenarios := []scenario{
|
||||
{"connected", stateConnected, true, true},
|
||||
{"mgm down", stateConnecting, false, true},
|
||||
{"signal down", stateConnecting, true, false},
|
||||
{"disconnected", stateDisconnected, false, false},
|
||||
{"connected", ClientStateConnected, true, true},
|
||||
{"mgm down", ClientStateConnecting, false, true},
|
||||
{"signal down", ClientStateConnecting, true, false},
|
||||
{"disconnected", ClientStateDisconnected, false, false},
|
||||
}
|
||||
|
||||
for _, tt := range scenarios {
|
||||
@@ -85,7 +88,7 @@ func Test_notifier_SetListener(t *testing.T) {
|
||||
listener.setPeersWaiter()
|
||||
|
||||
n := newNotifier()
|
||||
n.lastNotification = stateConnecting
|
||||
n.lastNotification = ClientStateConnecting
|
||||
n.setListener(listener)
|
||||
listener.wait()
|
||||
listener.waitPeers()
|
||||
@@ -99,7 +102,7 @@ func Test_notifier_RemoveListener(t *testing.T) {
|
||||
listener.setWaiter()
|
||||
listener.setPeersWaiter()
|
||||
n := newNotifier()
|
||||
n.lastNotification = stateConnecting
|
||||
n.lastNotification = ClientStateConnecting
|
||||
n.setListener(listener)
|
||||
// setListener replays cached state on a goroutine; wait for both the state
|
||||
// and peers callbacks to finish so we don't race on listener.peers.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1211,6 +1211,12 @@ func (d *Status) ClientTeardown() {
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// SetNetworkAvailable records the OS-reported network availability; while
|
||||
// unavailable, listeners see NoNetwork instead of Connecting.
|
||||
func (d *Status) SetNetworkAvailable(available bool) {
|
||||
d.notifier.setNetworkAvailable(available)
|
||||
}
|
||||
|
||||
// SetConnectionListener set a listener to the notifier
|
||||
func (d *Status) SetConnectionListener(listener Listener) {
|
||||
d.notifier.setListener(listener)
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -36,11 +38,6 @@ const (
|
||||
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile
|
||||
type ConnectionListener interface {
|
||||
peer.Listener
|
||||
}
|
||||
|
||||
// RouteListener export internal RouteListener for mobile
|
||||
type NetworkChangeListener interface {
|
||||
listener.NetworkChangeListener
|
||||
@@ -87,6 +84,12 @@ type Client struct {
|
||||
onHostDnsFn func([]string)
|
||||
dnsManager dns.IosDnsManager
|
||||
loginComplete bool
|
||||
// netState outlives engine restarts: it mirrors the OS connectivity, not
|
||||
// the engine lifecycle. Run injects it into each new ConnectClient, which
|
||||
// distributes it to every reconnection loop.
|
||||
netState *netstate.State
|
||||
// sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
|
||||
sweeper *netsweep.Sweeper
|
||||
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
|
||||
preloadedConfig *profilemanager.Config
|
||||
|
||||
@@ -109,6 +112,8 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
|
||||
ctxCancelLock: &sync.Mutex{},
|
||||
networkChangeListener: networkChangeListener,
|
||||
dnsManager: dnsManager,
|
||||
netState: netstate.New(),
|
||||
sweeper: netsweep.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +189,8 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
||||
c.onHostDnsFn = func([]string) {}
|
||||
cfg.WgIface = interfaceName
|
||||
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
|
||||
c.setState(cfg, connectClient)
|
||||
// Persist the latest sync response so DebugBundle can include the network
|
||||
// map. On iOS this is backed by disk to keep it out of the constrained
|
||||
@@ -193,6 +199,25 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
||||
return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath)
|
||||
}
|
||||
|
||||
// SetNetworkAvailable feeds OS-reported network availability into the client
|
||||
// (e.g. from NWPathMonitor). While unavailable, the internal reconnect loops
|
||||
// suspend their attempts and the connection listener reports NoNetwork
|
||||
// instead of Connecting; when availability returns, the loops resume
|
||||
// immediately with a fresh backoff.
|
||||
func (c *Client) SetNetworkAvailable(available bool) {
|
||||
c.netState.Set(available)
|
||||
c.recorder.SetNetworkAvailable(available)
|
||||
}
|
||||
|
||||
// NotifyNetworkChange marks the management, signal and relay connections
|
||||
// stale after the OS switched networks and schedules a sweep that cuts
|
||||
// whatever has not redialed on the new network by then. The engine and the
|
||||
// TUN device stay untouched.
|
||||
func (c *Client) NotifyNetworkChange() {
|
||||
c.sweeper.MarkNetworkChange()
|
||||
log.Infof("network change: connections marked stale")
|
||||
}
|
||||
|
||||
// Stop the internal client and free the resources
|
||||
func (c *Client) Stop() {
|
||||
c.ctxCancelLock.Lock()
|
||||
@@ -331,7 +356,11 @@ func (c *Client) GetStatusDetails() *StatusDetails {
|
||||
|
||||
// SetConnectionListener set the network connection listener
|
||||
func (c *Client) SetConnectionListener(listener ConnectionListener) {
|
||||
c.recorder.SetConnectionListener(listener)
|
||||
if listener == nil {
|
||||
c.recorder.RemoveConnectionListener()
|
||||
return
|
||||
}
|
||||
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
|
||||
}
|
||||
|
||||
// RemoveConnectionListener remove connection listener
|
||||
|
||||
43
client/ios/NetBirdSDK/connection_listener.go
Normal file
43
client/ios/NetBirdSDK/connection_listener.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build ios
|
||||
|
||||
package NetBirdSDK
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// Client state values, re-exported as basic constants so gomobile emits them
|
||||
// into the generated bindings. They mirror peer.ClientState*: append-only,
|
||||
// never reorder.
|
||||
const (
|
||||
ClientStateDisconnected = int(peer.ClientStateDisconnected)
|
||||
ClientStateConnected = int(peer.ClientStateConnected)
|
||||
ClientStateConnecting = int(peer.ClientStateConnecting)
|
||||
ClientStateDisconnecting = int(peer.ClientStateDisconnecting)
|
||||
ClientStateNoNetwork = int(peer.ClientStateNoNetwork)
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile.
|
||||
//
|
||||
// It intentionally lacks OnStateChanged for now: adding a method to a gomobile
|
||||
// interface breaks every Swift implementation, so the iOS app keeps building
|
||||
// against the legacy per-state callbacks. A follow-up will extend it together
|
||||
// with the app.
|
||||
type ConnectionListener interface {
|
||||
OnConnected()
|
||||
OnDisconnected()
|
||||
OnConnecting()
|
||||
OnDisconnecting()
|
||||
OnAddressChanged(string, string)
|
||||
OnPeersListChanged(int)
|
||||
}
|
||||
|
||||
// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to
|
||||
// peer.Listener.
|
||||
type connectionListenerAdapter struct {
|
||||
ConnectionListener
|
||||
}
|
||||
|
||||
// OnStateChanged is dropped on iOS until the app adopts the state callback;
|
||||
// the legacy per-state callbacks continue to fire.
|
||||
func (a connectionListenerAdapter) OnStateChanged(peer.ClientState) {}
|
||||
107
client/netstate/netstate.go
Normal file
107
client/netstate/netstate.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Package netstate tracks OS-reported network availability for the client.
|
||||
//
|
||||
// A State instance is owned by the platform integration (e.g. the Android or
|
||||
// iOS bindings, fed from ConnectivityManager callbacks or NWPathMonitor) and
|
||||
// is injected into the connection retry loops (management, signal, relay,
|
||||
// peer guards and the top-level connect loop), which consult it to avoid
|
||||
// burning CPU and battery on reconnect attempts while the device has no
|
||||
// network at all (e.g. airplane mode), and to reset their backoff as soon as
|
||||
// the network returns.
|
||||
//
|
||||
// Consumers hold a *State that may be nil — every non-mobile platform leaves
|
||||
// it unset. The read methods are safe on a nil receiver: they report online
|
||||
// and never block, so consumers behave as if this package did not exist.
|
||||
package netstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// State holds the OS-reported network availability. The zero value is not
|
||||
// usable; create instances with New.
|
||||
type State struct {
|
||||
mu sync.Mutex
|
||||
online bool
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
// New creates a State that starts online.
|
||||
func New() *State {
|
||||
return &State{
|
||||
online: true,
|
||||
changed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Set records whether the OS reports any usable network. Transitions wake up
|
||||
// all Wait callers immediately.
|
||||
func (s *State) Set(online bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.online == online {
|
||||
return
|
||||
}
|
||||
s.online = online
|
||||
close(s.changed)
|
||||
s.changed = make(chan struct{})
|
||||
log.Infof("OS network availability changed: online=%t", online)
|
||||
}
|
||||
|
||||
// IsOnline reports whether the OS reports at least one usable network. On a
|
||||
// nil receiver — no State injected — it reports online.
|
||||
func (s *State) IsOnline() bool {
|
||||
if s == nil {
|
||||
return true
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.online
|
||||
}
|
||||
|
||||
// Changed returns a channel closed on the next availability transition, for
|
||||
// callers that already own a select loop and cannot block in Wait. Re-read it
|
||||
// after every fire: each transition installs a fresh channel. On a nil
|
||||
// receiver — no State injected — it returns nil, which blocks forever in a
|
||||
// select, so the caller simply never observes a transition.
|
||||
func (s *State) Changed() <-chan struct{} {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.changed
|
||||
}
|
||||
|
||||
// Wait blocks while the network is offline. It reports whether it had to
|
||||
// wait, so callers can reset their backoff after an outage. It returns early
|
||||
// with the context error when ctx is done. On a nil receiver — no State
|
||||
// injected — it returns immediately.
|
||||
func (s *State) Wait(ctx context.Context) (bool, error) {
|
||||
if s == nil {
|
||||
return false, nil
|
||||
}
|
||||
waited := false
|
||||
for {
|
||||
s.mu.Lock()
|
||||
if s.online {
|
||||
s.mu.Unlock()
|
||||
return waited, nil
|
||||
}
|
||||
ch := s.changed
|
||||
s.mu.Unlock()
|
||||
|
||||
if !waited {
|
||||
waited = true
|
||||
log.Debugf("network is offline, pausing connection attempts")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return waited, ctx.Err()
|
||||
case <-ch:
|
||||
}
|
||||
}
|
||||
}
|
||||
170
client/netstate/netstate_test.go
Normal file
170
client/netstate/netstate_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package netstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewStateIsOnline(t *testing.T) {
|
||||
assert.True(t, New().IsOnline(), "a fresh State should start online")
|
||||
}
|
||||
|
||||
func TestSetTogglesOnlineState(t *testing.T) {
|
||||
s := New()
|
||||
|
||||
s.Set(false)
|
||||
assert.False(t, s.IsOnline(), "state should be offline after Set(false)")
|
||||
|
||||
s.Set(true)
|
||||
assert.True(t, s.IsOnline(), "state should be online after Set(true)")
|
||||
}
|
||||
|
||||
func TestWaitReturnsImmediatelyWhenOnline(t *testing.T) {
|
||||
s := New()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
waited, err := s.Wait(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, waited, "Wait should not block when the network is online")
|
||||
}
|
||||
|
||||
func TestWaitBlocksUntilOnline(t *testing.T) {
|
||||
s := New()
|
||||
s.Set(false)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result := make(chan bool, 1)
|
||||
go func() {
|
||||
waited, err := s.Wait(ctx)
|
||||
if err != nil {
|
||||
result <- false
|
||||
return
|
||||
}
|
||||
result <- waited
|
||||
}()
|
||||
|
||||
// Verify Wait is actually blocking while offline
|
||||
select {
|
||||
case <-result:
|
||||
t.Fatal("Wait should block while the network is offline")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
s.Set(true)
|
||||
|
||||
select {
|
||||
case waited := <-result:
|
||||
assert.True(t, waited, "Wait should report that it had to wait for the network")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Wait should return promptly after the network becomes available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitReturnsOnContextCancel(t *testing.T) {
|
||||
s := New()
|
||||
s.Set(false)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.Wait(ctx)
|
||||
result <- err
|
||||
}()
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-result:
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Wait should return promptly after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitWakesAllWaiters(t *testing.T) {
|
||||
s := New()
|
||||
s.Set(false)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const waiters = 10
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan bool, waiters)
|
||||
for i := 0; i < waiters; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
waited, err := s.Wait(ctx)
|
||||
if err != nil {
|
||||
results <- false
|
||||
return
|
||||
}
|
||||
results <- waited
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
s.Set(true)
|
||||
wg.Wait()
|
||||
|
||||
close(results)
|
||||
count := 0
|
||||
for waited := range results {
|
||||
assert.True(t, waited, "every waiter should report that it waited")
|
||||
count++
|
||||
}
|
||||
assert.Equal(t, waiters, count, "all waiters should have returned")
|
||||
}
|
||||
|
||||
func TestNilStateReadsAreNoops(t *testing.T) {
|
||||
var s *State
|
||||
|
||||
assert.True(t, s.IsOnline(), "nil State should report online")
|
||||
|
||||
waited, err := s.Wait(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.False(t, waited, "nil State's Wait should not block")
|
||||
}
|
||||
|
||||
func TestConcurrentSetAndWait(t *testing.T) {
|
||||
s := New()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
s.Set(j%2 == 0)
|
||||
s.IsOnline()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
if _, err := s.Wait(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
267
client/netsweep/netsweep.go
Normal file
267
client/netsweep/netsweep.go
Normal file
@@ -0,0 +1,267 @@
|
||||
// Package netsweep cuts network-bound activity when the OS switches networks:
|
||||
// a sweep closes the registered connections and aborts the in-flight dials, so
|
||||
// their owners redial immediately instead of waiting for the old sockets to
|
||||
// time out.
|
||||
//
|
||||
// A nil *Sweeper disables everything: all methods are nil-safe no-ops.
|
||||
package netsweep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
// DefaultSweepDelay absorbs network flapping while the OS settles on a
|
||||
// default network before the stale registrations are cut.
|
||||
const DefaultSweepDelay = 500 * time.Millisecond
|
||||
|
||||
const recentMarkWindow = 3 * time.Second
|
||||
|
||||
// Config customizes a Sweeper. The zero value applies the defaults.
|
||||
type Config struct {
|
||||
// SweepDelay overrides DefaultSweepDelay when positive.
|
||||
SweepDelay time.Duration
|
||||
}
|
||||
|
||||
// ErrSwept reports that a dial finished after a network change swept its
|
||||
// registration. The connection is already closed; the caller must treat it
|
||||
// as a failed dial and redial on the new network.
|
||||
var ErrSwept = errors.New("netsweep: connection swept by network change")
|
||||
|
||||
// sweepID identifies one registration in a sweeper. Connections and dials
|
||||
// draw from the same counter, so an id is unique across both registries.
|
||||
type sweepID uint64
|
||||
|
||||
type connEntry struct {
|
||||
conn net.Conn
|
||||
gen uint64
|
||||
}
|
||||
|
||||
// Dial tracks one dial from start to connection registration. It hands the
|
||||
// dialed connection to the sweeper atomically, so a sweep can never fall
|
||||
// between the dial finishing and the connection being registered.
|
||||
type Dial struct {
|
||||
sweeper *Sweeper
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
id sweepID
|
||||
done bool // set by a sweep, WrapConn or Release; guarded by sweeper.mu
|
||||
gen uint64
|
||||
}
|
||||
|
||||
// Ctx returns the dial's context. A sweep cancels it, so a dial started on the
|
||||
// old network aborts instead of waiting out its handshake timeout.
|
||||
func (d *Dial) Ctx() context.Context {
|
||||
return d.ctx
|
||||
}
|
||||
|
||||
// Release ends the dial's registration and cancels its context. It is
|
||||
// idempotent and safe after WrapConn, so callers can defer it.
|
||||
func (d *Dial) Release() {
|
||||
s := d.sweeper
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
d.done = true
|
||||
delete(s.dials, d.id)
|
||||
s.mu.Unlock()
|
||||
|
||||
d.cancel()
|
||||
}
|
||||
|
||||
// sweptConn deregisters itself from the sweeper when closed.
|
||||
type sweptConn struct {
|
||||
net.Conn
|
||||
sweeper *Sweeper
|
||||
id sweepID
|
||||
}
|
||||
|
||||
func (c *sweptConn) Close() error {
|
||||
c.sweeper.deregister(c.id)
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// Sweeper registers live connections and in-flight dials so the
|
||||
// network-change sweep can cut everything registered before the change.
|
||||
type Sweeper struct {
|
||||
mu sync.Mutex
|
||||
conns map[sweepID]connEntry
|
||||
dials map[sweepID]*Dial
|
||||
nextID sweepID
|
||||
gen uint64
|
||||
timer *time.Timer
|
||||
sweepDelay time.Duration
|
||||
lastMark time.Time
|
||||
}
|
||||
|
||||
// New creates an empty sweeper with the default configuration.
|
||||
func New() *Sweeper {
|
||||
return NewWithConfig(Config{})
|
||||
}
|
||||
|
||||
// NewWithConfig creates an empty sweeper customized by cfg.
|
||||
func NewWithConfig(cfg Config) *Sweeper {
|
||||
delay := cfg.SweepDelay
|
||||
if delay <= 0 {
|
||||
delay = DefaultSweepDelay
|
||||
}
|
||||
return &Sweeper{
|
||||
conns: make(map[sweepID]connEntry),
|
||||
dials: make(map[sweepID]*Dial),
|
||||
sweepDelay: delay,
|
||||
}
|
||||
}
|
||||
|
||||
// StartDial registers an in-flight dial. Dial with Ctx, hand the result to
|
||||
// WrapConn, and Release the dial when the attempt is over, typically deferred.
|
||||
func (s *Sweeper) StartDial(ctx context.Context) *Dial {
|
||||
if s == nil {
|
||||
return &Dial{ctx: ctx}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
d := &Dial{sweeper: s, ctx: ctx, cancel: cancel}
|
||||
|
||||
s.mu.Lock()
|
||||
d.id = s.nextID
|
||||
s.nextID++
|
||||
d.gen = s.gen
|
||||
s.dials[d.id] = d
|
||||
s.mu.Unlock()
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// WrapConn hands conn over to the sweeper. If a sweep ran since StartDial,
|
||||
// the connection belongs to the old network: it is closed and ErrSwept is
|
||||
// returned. Otherwise conn is registered against the next sweep and returned
|
||||
// wrapped, deregistering itself on Close. Call it once, before Release.
|
||||
func (d *Dial) WrapConn(conn net.Conn) (net.Conn, error) {
|
||||
s := d.sweeper
|
||||
if s == nil {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if d.done {
|
||||
s.mu.Unlock()
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("swept dial close error: %v", err)
|
||||
}
|
||||
return nil, ErrSwept
|
||||
}
|
||||
d.done = true
|
||||
delete(s.dials, d.id)
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
// The conn inherits the dial's generation: the socket was bound to the
|
||||
// network that was default when the dial started, not when it finished.
|
||||
s.conns[id] = connEntry{conn: conn, gen: d.gen}
|
||||
s.mu.Unlock()
|
||||
|
||||
return &sweptConn{Conn: conn, sweeper: s, id: id}, nil
|
||||
}
|
||||
|
||||
// MarkNetworkChange records that the OS switched networks: everything
|
||||
// registered so far becomes stale, and a sweep is (re)scheduled after the
|
||||
// configured delay to cut whatever is still stale by then. Owners that
|
||||
// redialed in the meantime hold fresh-generation registrations and survive,
|
||||
// so no cancellation is needed around the sweep.
|
||||
func (s *Sweeper) MarkNetworkChange() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.gen++
|
||||
cutoff := s.gen
|
||||
s.lastMark = time.Now()
|
||||
if s.timer != nil {
|
||||
s.timer.Stop()
|
||||
}
|
||||
s.timer = time.AfterFunc(s.sweepDelay, func() {
|
||||
n := s.sweep(cutoff)
|
||||
log.Infof("network change sweep: closed %d stale connections", n)
|
||||
})
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// QuickRetryBackoff wraps bo so that after each Reset the first retry comes
|
||||
// quickly when the disconnect followed a recent network change and the
|
||||
// network is online. Any other failure keeps bo's spread, so the clients of
|
||||
// a restarted server still scatter their reconnects. A nil sweeper returns
|
||||
// bo unchanged.
|
||||
func (s *Sweeper) QuickRetryBackoff(ctx context.Context, bo backoff.BackOff, netState *netstate.State) backoff.BackOff {
|
||||
if s == nil {
|
||||
return bo
|
||||
}
|
||||
return backoff.WithContext(newQuickRetryBackoff(bo, s, netState), ctx)
|
||||
}
|
||||
|
||||
func (s *Sweeper) markedRecently() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return !s.lastMark.IsZero() && time.Since(s.lastMark) < recentMarkWindow
|
||||
}
|
||||
|
||||
// sweep closes the registered connections and aborts the in-flight dials
|
||||
// older than cutoff, and returns how many connections it closed. A dial
|
||||
// whose connection was not yet handed to WrapConn is marked, so the late
|
||||
// WrapConn closes it instead of registering it.
|
||||
func (s *Sweeper) sweep(cutoff uint64) int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
var conns []net.Conn
|
||||
for id, e := range s.conns {
|
||||
if e.gen < cutoff {
|
||||
delete(s.conns, id)
|
||||
conns = append(conns, e.conn)
|
||||
}
|
||||
}
|
||||
var dials []*Dial
|
||||
for id, d := range s.dials {
|
||||
if d.gen < cutoff {
|
||||
d.done = true
|
||||
delete(s.dials, id)
|
||||
dials = append(dials, d)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(dials) > 0 {
|
||||
log.Debugf("aborting %d in-flight dials", len(dials))
|
||||
for _, d := range dials {
|
||||
d.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
for _, conn := range conns {
|
||||
log.Debugf("sweeping connection %s -> %s", conn.LocalAddr(), conn.RemoteAddr())
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("swept connection close error: %v", err)
|
||||
}
|
||||
}
|
||||
return len(conns)
|
||||
}
|
||||
|
||||
func (s *Sweeper) deregister(id sweepID) {
|
||||
s.mu.Lock()
|
||||
delete(s.conns, id)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
241
client/netsweep/netsweep_test.go
Normal file
241
client/netsweep/netsweep_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package netsweep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSweepClosesRegisteredConns(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
c1 := wrap(t, sweeper, connPair(t))
|
||||
c2 := wrap(t, sweeper, connPair(t))
|
||||
|
||||
assert.Equal(t, 2, sweeper.sweepAll(), "both live connections should be closed")
|
||||
|
||||
// The wrappers must report closed now.
|
||||
buf := make([]byte, 1)
|
||||
_, err := c1.Read(buf)
|
||||
assert.Error(t, err, "first connection should be unusable after the sweep")
|
||||
_, err = c2.Read(buf)
|
||||
assert.Error(t, err, "second connection should be unusable after the sweep")
|
||||
|
||||
assert.Equal(t, 0, sweeper.sweepAll(), "second sweep should find nothing")
|
||||
}
|
||||
|
||||
func TestCloseDeregisters(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
conn := wrap(t, sweeper, connPair(t))
|
||||
require.NoError(t, conn.Close())
|
||||
|
||||
assert.Equal(t, 0, sweeper.sweepAll(), "closed connection must leave the registry")
|
||||
}
|
||||
|
||||
func TestCloseIsIdempotent(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
conn := wrap(t, sweeper, connPair(t))
|
||||
require.NoError(t, conn.Close())
|
||||
assert.Error(t, conn.Close(), "double close surfaces the underlying error but must not panic")
|
||||
}
|
||||
|
||||
func TestSweepOnlyAffectsOlderConns(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
_ = wrap(t, sweeper, connPair(t))
|
||||
assert.Equal(t, 1, sweeper.sweepAll())
|
||||
|
||||
// A connection dialed after the sweep must survive until the next one.
|
||||
_ = wrap(t, sweeper, connPair(t))
|
||||
assert.Equal(t, 1, sweeper.sweepAll(), "post-sweep connection belongs to the next sweep")
|
||||
}
|
||||
|
||||
func TestSweepAbortsInFlightDials(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
dial := sweeper.StartDial(context.Background())
|
||||
defer dial.Release()
|
||||
|
||||
sweeper.sweepAll()
|
||||
|
||||
assert.ErrorIs(t, dial.Ctx().Err(), context.Canceled, "sweep must cancel the in-flight dial context")
|
||||
}
|
||||
|
||||
func TestReleasedDialIsNotAborted(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
// Simulate a dial that finished before the sweep.
|
||||
released := sweeper.StartDial(context.Background())
|
||||
released.Release()
|
||||
|
||||
// A dial still in flight during the sweep.
|
||||
pending := sweeper.StartDial(context.Background())
|
||||
defer pending.Release()
|
||||
|
||||
sweeper.sweepAll()
|
||||
assert.ErrorIs(t, pending.Ctx().Err(), context.Canceled, "pending dial must be aborted")
|
||||
}
|
||||
|
||||
func TestSweepBetweenDialAndHandoffClosesConn(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
dial := sweeper.StartDial(context.Background())
|
||||
defer dial.Release()
|
||||
|
||||
// The dial succeeds on the old network, then the sweep lands before the
|
||||
// connection is handed over.
|
||||
conn := connPair(t)
|
||||
assert.Equal(t, 0, sweeper.sweepAll(), "the connection is not registered yet")
|
||||
|
||||
wrapped, err := dial.WrapConn(conn)
|
||||
require.ErrorIs(t, err, ErrSwept)
|
||||
require.Nil(t, wrapped)
|
||||
|
||||
buf := make([]byte, 1)
|
||||
_, err = conn.Read(buf)
|
||||
assert.Error(t, err, "the old-network connection must be closed, not leaked")
|
||||
|
||||
assert.Equal(t, 0, sweeper.sweepAll(), "nothing may leak into the next sweep")
|
||||
}
|
||||
|
||||
func TestMarkNetworkChangeSparesFreshConns(t *testing.T) {
|
||||
sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond})
|
||||
|
||||
stale := wrap(t, sweeper, connPair(t))
|
||||
sweeper.MarkNetworkChange()
|
||||
_ = wrap(t, sweeper, connPair(t))
|
||||
|
||||
_ = stale.SetReadDeadline(time.Now().Add(time.Second))
|
||||
buf := make([]byte, 1)
|
||||
_, err := stale.Read(buf)
|
||||
require.ErrorIs(t, err, net.ErrClosed, "stale connection must be closed by the delayed sweep")
|
||||
|
||||
assert.Equal(t, 1, sweeper.sweepAll(), "the fresh connection must survive the stale sweep")
|
||||
}
|
||||
|
||||
func TestMarkNetworkChangeAbortsStaleDials(t *testing.T) {
|
||||
sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond})
|
||||
|
||||
stale := sweeper.StartDial(context.Background())
|
||||
defer stale.Release()
|
||||
sweeper.MarkNetworkChange()
|
||||
fresh := sweeper.StartDial(context.Background())
|
||||
defer fresh.Release()
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
return stale.Ctx().Err() != nil
|
||||
}, time.Second, 5*time.Millisecond, "stale dial must be aborted by the delayed sweep")
|
||||
assert.NoError(t, fresh.Ctx().Err(), "post-mark dial must not be aborted")
|
||||
}
|
||||
|
||||
func TestConnInheritsDialGeneration(t *testing.T) {
|
||||
sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond})
|
||||
|
||||
// The dial starts before the network change but completes after it: the
|
||||
// socket is bound to the old network, so the sweep must still cut it.
|
||||
dial := sweeper.StartDial(context.Background())
|
||||
defer dial.Release()
|
||||
sweeper.MarkNetworkChange()
|
||||
|
||||
wrapped, err := dial.WrapConn(connPair(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
_ = wrapped.SetReadDeadline(time.Now().Add(time.Second))
|
||||
buf := make([]byte, 1)
|
||||
_, err = wrapped.Read(buf)
|
||||
require.ErrorIs(t, err, net.ErrClosed, "old-generation connection must be swept")
|
||||
}
|
||||
|
||||
func TestRepeatedMarksCoalesce(t *testing.T) {
|
||||
sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond})
|
||||
|
||||
first := wrap(t, sweeper, connPair(t))
|
||||
sweeper.MarkNetworkChange()
|
||||
second := wrap(t, sweeper, connPair(t))
|
||||
sweeper.MarkNetworkChange()
|
||||
_ = wrap(t, sweeper, connPair(t))
|
||||
|
||||
buf := make([]byte, 1)
|
||||
for _, conn := range []net.Conn{first, second} {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
_, err := conn.Read(buf)
|
||||
require.ErrorIs(t, err, net.ErrClosed, "every pre-mark connection must be swept by the rescheduled sweep")
|
||||
}
|
||||
assert.Equal(t, 1, sweeper.sweepAll(), "only the newest-generation connection may remain")
|
||||
}
|
||||
|
||||
func TestNilSweeperIsNoop(t *testing.T) {
|
||||
var sweeper *Sweeper
|
||||
|
||||
conn := connPair(t)
|
||||
dial := sweeper.StartDial(context.Background())
|
||||
defer dial.Release()
|
||||
|
||||
wrapped, err := dial.WrapConn(conn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, conn, wrapped, "nil sweeper must return the conn unchanged")
|
||||
assert.NoError(t, dial.Ctx().Err(), "nil sweeper must not cancel the dial context")
|
||||
assert.Equal(t, 0, sweeper.sweepAll(), "nil sweeper closes nothing")
|
||||
}
|
||||
|
||||
// wrap registers conn with the sweeper through a completed dial.
|
||||
func wrap(t *testing.T, sweeper *Sweeper, conn net.Conn) net.Conn {
|
||||
t.Helper()
|
||||
|
||||
dial := sweeper.StartDial(context.Background())
|
||||
defer dial.Release()
|
||||
|
||||
wrapped, err := dial.WrapConn(conn)
|
||||
require.NoError(t, err)
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// connPair dials a loopback TCP connection and keeps the accepted peer open
|
||||
// until the test ends: a peer that closed early would make the connection
|
||||
// unreadable on its own, so a read error after the sweep would prove nothing.
|
||||
func connPair(t *testing.T) net.Conn {
|
||||
t.Helper()
|
||||
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := l.Close(); err != nil {
|
||||
t.Logf("listener close error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
accepted := make(chan net.Conn, 1)
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
close(accepted)
|
||||
return
|
||||
}
|
||||
accepted <- conn
|
||||
}()
|
||||
|
||||
conn, err := net.Dial("tcp", l.Addr().String())
|
||||
require.NoError(t, err)
|
||||
|
||||
peer, ok := <-accepted
|
||||
require.True(t, ok, "listener must accept the dialed connection")
|
||||
t.Cleanup(func() {
|
||||
if err := peer.Close(); err != nil {
|
||||
t.Logf("peer close error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
// sweepAll cuts every registration regardless of generation.
|
||||
func (s *Sweeper) sweepAll() int {
|
||||
return s.sweep(math.MaxUint64)
|
||||
}
|
||||
39
client/netsweep/quick_retry.go
Normal file
39
client/netsweep/quick_retry.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package netsweep
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
const quickRetryDelay = 200 * time.Millisecond
|
||||
|
||||
type quickRetryBackoff struct {
|
||||
backoff.BackOff
|
||||
sweeper *Sweeper
|
||||
netState *netstate.State
|
||||
used bool
|
||||
}
|
||||
|
||||
func newQuickRetryBackoff(bo backoff.BackOff, sweeper *Sweeper, netState *netstate.State) *quickRetryBackoff {
|
||||
return &quickRetryBackoff{
|
||||
BackOff: bo,
|
||||
sweeper: sweeper,
|
||||
netState: netState,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *quickRetryBackoff) NextBackOff() time.Duration {
|
||||
if !b.used && b.sweeper.markedRecently() && b.netState.IsOnline() {
|
||||
b.used = true
|
||||
return quickRetryDelay
|
||||
}
|
||||
return b.BackOff.NextBackOff()
|
||||
}
|
||||
|
||||
func (b *quickRetryBackoff) Reset() {
|
||||
b.used = false
|
||||
b.BackOff.Reset()
|
||||
}
|
||||
58
client/netsweep/quick_retry_test.go
Normal file
58
client/netsweep/quick_retry_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package netsweep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestQuickRetryAfterRecentMark(t *testing.T) {
|
||||
sweeper := New()
|
||||
sweeper.MarkNetworkChange()
|
||||
|
||||
slow := backoff.NewConstantBackOff(5 * time.Second)
|
||||
bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil)
|
||||
|
||||
assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "first retry after a mark must be quick")
|
||||
assert.Equal(t, 5*time.Second, bo.NextBackOff(), "second retry must fall back to the wrapped backoff")
|
||||
|
||||
bo.Reset()
|
||||
assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "reset must re-arm the quick retry")
|
||||
}
|
||||
|
||||
func TestQuickRetryWithoutMarkKeepsSpread(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
slow := backoff.NewConstantBackOff(5 * time.Second)
|
||||
bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil)
|
||||
|
||||
assert.Equal(t, 5*time.Second, bo.NextBackOff(), "without a mark the wrapped backoff decides")
|
||||
|
||||
sweeper.mu.Lock()
|
||||
sweeper.lastMark = time.Now().Add(-recentMarkWindow)
|
||||
sweeper.mu.Unlock()
|
||||
assert.Equal(t, 5*time.Second, bo.NextBackOff(), "a stale mark must not trigger the quick retry")
|
||||
}
|
||||
|
||||
func TestQuickRetryNilSweeperPassthrough(t *testing.T) {
|
||||
var sweeper *Sweeper
|
||||
|
||||
slow := backoff.NewConstantBackOff(5 * time.Second)
|
||||
bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil)
|
||||
|
||||
assert.Equal(t, backoff.BackOff(slow), bo, "nil sweeper must return the backoff unchanged")
|
||||
}
|
||||
|
||||
func TestQuickRetryHonorsContext(t *testing.T) {
|
||||
sweeper := New()
|
||||
sweeper.MarkNetworkChange()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
bo := sweeper.QuickRetryBackoff(ctx, backoff.NewConstantBackOff(time.Millisecond), nil)
|
||||
|
||||
assert.Equal(t, backoff.Stop, bo.NextBackOff(), "cancelled context must stop the retry loop")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<string, FileDropRule> peerRules = 3;
|
||||
}
|
||||
|
||||
message FileDropSetSettingsRequest {
|
||||
FileDropMode mode = 1;
|
||||
string destinationDir = 2;
|
||||
}
|
||||
|
||||
message FileDropSetSettingsResponse {}
|
||||
|
||||
message FileDropSetPeerRuleRequest {
|
||||
string peerKey = 1;
|
||||
FileDropRule rule = 2;
|
||||
}
|
||||
|
||||
message FileDropSetPeerRuleResponse {}
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
412
client/server/filedrop.go
Normal file
412
client/server/filedrop.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
25
client/server/filedrop_unix.go
Normal file
25
client/server/filedrop_unix.go
Normal file
@@ -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
|
||||
}
|
||||
11
client/server/filedrop_windows.go
Normal file
11
client/server/filedrop_windows.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
47
client/ui/frontend/src/lib/filedrop.ts
Normal file
47
client/ui/frontend/src/lib/filedrop.ts
Normal file
@@ -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";
|
||||
}
|
||||
};
|
||||
@@ -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 (
|
||||
<main className={"wails-draggable flex min-h-0 flex-1"}>
|
||||
// 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.
|
||||
<main className={"wails-draggable flex min-h-0 min-w-0 flex-1"}>
|
||||
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
|
||||
See https://github.com/wailsapp/wails/issues/3260 */}
|
||||
<div
|
||||
@@ -98,10 +102,14 @@ const AdvancedAppRightPanel = () => {
|
||||
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" && <Peers />}
|
||||
{section === "networks" && <Networks />}
|
||||
{section === "files" && <Files />}
|
||||
</div>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
|
||||
@@ -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<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
<Icon size={14} aria-hidden={"true"} />
|
||||
<span className={"text-sm font-normal"}>{tab.label}</span>
|
||||
<Icon size={14} className={"shrink-0"} aria-hidden={"true"} />
|
||||
<span className={"truncate text-sm font-normal"}>{tab.label}</span>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
|
||||
494
client/ui/frontend/src/modules/main/advanced/files/Files.tsx
Normal file
494
client/ui/frontend/src/modules/main/advanced/files/Files.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
BanIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
FolderDownIcon,
|
||||
FolderOpenIcon,
|
||||
MoreVerticalIcon,
|
||||
ShieldCheckIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { FileDrop } from "@bindings/services";
|
||||
import type { FileDropTransfer } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatBytes } from "@/lib/formatters";
|
||||
import {
|
||||
FailureReason,
|
||||
isTerminalState,
|
||||
PeerRule,
|
||||
stateLabelKey,
|
||||
TransferState,
|
||||
} from "@/lib/filedrop";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
|
||||
const EVENT_FILEDROP = "netbird:filedrop";
|
||||
const POLL_MS = 1000;
|
||||
|
||||
const transferTitle = (transfer: FileDropTransfer): string => {
|
||||
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<FileDropTransfer[] | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(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<string, FileDropTransfer[]>();
|
||||
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 (
|
||||
<EmptyState
|
||||
icon={FolderDownIcon}
|
||||
title={t("files.empty.title")}
|
||||
description={t("files.empty.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"flex h-full min-h-0 w-full flex-col"}>
|
||||
<div className={"flex items-center gap-2 border-b border-nb-gray-910 px-6 py-2.5"}>
|
||||
<div className={"min-w-0 flex-1"}>
|
||||
<SearchInput
|
||||
ref={searchRef}
|
||||
placeholder={t("files.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<div className={"flex flex-col pb-4 pt-2"}>
|
||||
{pending.map((tr) => (
|
||||
<PendingOfferRow key={tr.id} transfer={tr} onChanged={refresh} />
|
||||
))}
|
||||
{groups.map((group) => (
|
||||
<div key={group.label} className={"flex flex-col"}>
|
||||
<div
|
||||
className={
|
||||
"px-6 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider text-nb-gray-500"
|
||||
}
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
{group.list.map((tr) => (
|
||||
<TransferRow
|
||||
key={tr.id}
|
||||
transfer={tr}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={"flex w-1.5 touch-none select-none bg-transparent py-1"}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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.
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 mt-2 flex flex-col gap-2.5 rounded-lg border border-netbird/30",
|
||||
"bg-netbird/5 px-4 py-3",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
<div className={"flex min-w-0 items-center gap-2.5"}>
|
||||
<ArrowDownIcon
|
||||
size={16}
|
||||
className={"shrink-0 text-netbird"}
|
||||
aria-hidden={"true"}
|
||||
/>
|
||||
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
|
||||
<span className={"truncate text-[0.81rem] font-medium text-nb-gray-100"}>
|
||||
{transferTitle(transfer)}
|
||||
<span className={"font-normal text-nb-gray-400"}>
|
||||
{" · "}
|
||||
{formatBytes(transfer.totalSize)}
|
||||
</span>
|
||||
</span>
|
||||
<span className={"truncate text-xs text-nb-gray-400"}>
|
||||
{t("files.offer.subtitle", { peer: transfer.peerName })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex items-center gap-2 pl-[26px]"}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"xs"}
|
||||
disabled={busy}
|
||||
onClick={() => void decide(true)}
|
||||
>
|
||||
{t("files.offer.accept")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
disabled={busy}
|
||||
onClick={() => void decide(false)}
|
||||
>
|
||||
{t("files.offer.decline")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex items-center gap-2.5 py-2.5 pl-6 pr-4",
|
||||
"transition-colors hover:bg-nb-gray-900/40",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
{transfer.outgoing ? (
|
||||
<ArrowUpIcon size={15} className={"shrink-0 text-netbird"} aria-hidden={"true"} />
|
||||
) : (
|
||||
<ArrowDownIcon
|
||||
size={15}
|
||||
className={"shrink-0 text-green-400"}
|
||||
aria-hidden={"true"}
|
||||
/>
|
||||
)}
|
||||
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-[0.81rem] font-medium text-nb-gray-100",
|
||||
isText && "italic",
|
||||
)}
|
||||
>
|
||||
{transferTitle(transfer)}
|
||||
</span>
|
||||
<span className={"truncate text-xs text-nb-gray-400"}>
|
||||
{subtitleParts.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<span
|
||||
className={cn(
|
||||
"flex max-w-[8.5rem] shrink-0 flex-col items-end leading-tight",
|
||||
"pl-2 text-xs text-nb-gray-500",
|
||||
"transition-opacity group-hover:opacity-0",
|
||||
)}
|
||||
>
|
||||
<span className={"tabular-nums"}>{time}</span>
|
||||
<span className={cn("max-w-full truncate", stateClass)}>{stateLabel}</span>
|
||||
</span>
|
||||
{/* 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. */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-4 flex items-center gap-1",
|
||||
// Fades the covered outcome text out rather than sitting on
|
||||
// a flat block, which would not match the row's hover tint.
|
||||
"bg-gradient-to-l from-nb-gray-940 via-nb-gray-940 to-transparent pl-8",
|
||||
"opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
{isText && (
|
||||
<RowIconButton label={t("files.action.copy")} onClick={() => void copyText()}>
|
||||
{copied ? (
|
||||
<CheckIcon size={14} className={"text-green-400"} />
|
||||
) : (
|
||||
<CopyIcon size={14} />
|
||||
)}
|
||||
</RowIconButton>
|
||||
)}
|
||||
{delivered && (
|
||||
<RowIconButton
|
||||
label={t("files.action.reveal")}
|
||||
onClick={() => void FileDrop.Reveal(transfer.deliveredPaths[0])}
|
||||
>
|
||||
<FolderOpenIcon size={14} />
|
||||
</RowIconButton>
|
||||
)}
|
||||
{live && (
|
||||
<RowIconButton
|
||||
label={t("files.action.cancel")}
|
||||
onClick={async () => {
|
||||
await FileDrop.Cancel(transfer.id);
|
||||
onChanged();
|
||||
}}
|
||||
>
|
||||
<XIcon size={14} />
|
||||
</RowIconButton>
|
||||
)}
|
||||
<TransferMenu transfer={transfer} delivered={delivered} onChanged={onChanged} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RowIconButton = ({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Tooltip content={label}>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const TransferMenu = ({ transfer, delivered, onChanged }: RowProps & { delivered: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setRule = async (rule: PeerRule) => {
|
||||
await FileDrop.SetPeerRule(transfer.peerKey, rule);
|
||||
onChanged();
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={t("files.action.more")}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
)}
|
||||
>
|
||||
<MoreVerticalIcon size={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"}>
|
||||
{delivered && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => void FileDrop.Open(transfer.deliveredPaths[0])}
|
||||
>
|
||||
<FolderOpenIcon size={14} className={"mr-2"} />
|
||||
{t("files.action.open")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!transfer.outgoing && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => void setRule(PeerRule.Always)}>
|
||||
<ShieldCheckIcon size={14} className={"mr-2"} />
|
||||
{t("files.action.alwaysAccept")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => void setRule(PeerRule.Block)}>
|
||||
<BanIcon size={14} className={"mr-2"} />
|
||||
{t("files.action.block")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
variant={"danger"}
|
||||
onClick={async () => {
|
||||
await FileDrop.Delete(transfer.id);
|
||||
onChanged();
|
||||
}}
|
||||
>
|
||||
<Trash2Icon size={14} className={"mr-2"} />
|
||||
{t("files.action.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -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) => {
|
||||
</div>
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<PeerSendActions peer={selected} />
|
||||
<PeerDetails peer={selected} now={now} />
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
@@ -272,6 +278,76 @@ 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<string | null>(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 (
|
||||
<div className={"border-b border-nb-gray-920 px-5 py-3"}>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
disabled={!canSend}
|
||||
onClick={() => void sendFiles()}
|
||||
>
|
||||
<SendIcon size={12} aria-hidden={"true"} />
|
||||
{t("peers.details.sendFile")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
disabled={!canSend}
|
||||
onClick={() => void sendClipboard()}
|
||||
>
|
||||
<ClipboardIcon size={12} aria-hidden={"true"} />
|
||||
{t("peers.details.sendClipboard")}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <div className={"mt-2 text-xs text-red-400"}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
const { t } = useTranslation();
|
||||
const formatAge = (unix: number, fallback: string): string => {
|
||||
|
||||
@@ -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<StatusFilter>("all");
|
||||
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const dropTargetRef = useRef<string | null>(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 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Root
|
||||
type={"auto"}
|
||||
className={"min-h-0 flex-1 overflow-hidden"}
|
||||
onDragLeave={(e: DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
||||
setDropTargetBoth(null);
|
||||
}
|
||||
}}
|
||||
onDragOver={(e: DragEvent) => e.preventDefault()}
|
||||
onDrop={(e: DragEvent) => e.preventDefault()}
|
||||
>
|
||||
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
|
||||
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
|
||||
{scrollParent && (
|
||||
<PeersList
|
||||
data={filtered}
|
||||
scrollParent={scrollParent}
|
||||
dropTarget={dropTarget}
|
||||
onDropTarget={setDropTargetBoth}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
@@ -163,9 +215,11 @@ const ListTopSpacer = () => <div className={"h-2"} />;
|
||||
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<VirtuosoHandle>(null);
|
||||
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
@@ -221,9 +275,15 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
};
|
||||
|
||||
const ctx = useMemo<PeerRowContext>(
|
||||
() => ({ 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<Element>, 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<Element>, 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<Element>) => onKeyDown(e, index);
|
||||
return (
|
||||
<div
|
||||
onDragOver={(e: DragEvent) => {
|
||||
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 && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-2 inset-y-0.5 z-10",
|
||||
"flex items-center justify-center gap-2 rounded-lg",
|
||||
"border border-netbird bg-nb-gray-940/90 text-netbird",
|
||||
)}
|
||||
>
|
||||
<SendIcon size={14} aria-hidden={"true"} />
|
||||
<span className={"text-sm"}>{t("peers.dropToSend")}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
|
||||
222
client/ui/frontend/src/modules/settings/SettingsFileSharing.tsx
Normal file
222
client/ui/frontend/src/modules/settings/SettingsFileSharing.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BanIcon, CheckIcon, XIcon } from "lucide-react";
|
||||
import { FileDrop } from "@bindings/services";
|
||||
import { FileDropSettings } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { PeerRule, ReceiveMode } from "@/lib/filedrop";
|
||||
import { shortenDns } from "@/lib/formatters";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
|
||||
const MODES: { value: ReceiveMode; labelKey: string; helpKey: string }[] = [
|
||||
{
|
||||
value: ReceiveMode.Off,
|
||||
labelKey: "settings.fileSharing.mode.off",
|
||||
helpKey: "settings.fileSharing.mode.off.help",
|
||||
},
|
||||
{
|
||||
value: ReceiveMode.Ask,
|
||||
labelKey: "settings.fileSharing.mode.ask",
|
||||
helpKey: "settings.fileSharing.mode.ask.help",
|
||||
},
|
||||
{
|
||||
value: ReceiveMode.Auto,
|
||||
labelKey: "settings.fileSharing.mode.auto",
|
||||
helpKey: "settings.fileSharing.mode.auto.help",
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsFileSharing() {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const [settings, setSettings] = useState<FileDropSettings | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
FileDrop.GetSettings()
|
||||
.then(setSettings)
|
||||
.catch((e: unknown) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
const apply = useCallback(
|
||||
async (next: FileDropSettings) => {
|
||||
const prev = settings;
|
||||
setSettings(next);
|
||||
try {
|
||||
await FileDrop.SetSettings(next);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setSettings(prev);
|
||||
setError(String(e));
|
||||
}
|
||||
},
|
||||
[settings],
|
||||
);
|
||||
|
||||
const changeDirectory = async () => {
|
||||
if (!settings) return;
|
||||
const dir = await FileDrop.PickDirectory();
|
||||
if (!dir) return;
|
||||
await apply(new FileDropSettings({ ...settings, destinationDir: dir }));
|
||||
};
|
||||
|
||||
const removeRule = async (peerKey: string) => {
|
||||
if (!settings) return;
|
||||
try {
|
||||
await FileDrop.SetPeerRule(peerKey, PeerRule.Default);
|
||||
setSettings(await FileDrop.GetSettings());
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const peerName = (key: string): string => {
|
||||
const peer = (status?.peers ?? []).find((p) => p.pubKey === key);
|
||||
return peer ? shortenDns(peer.fqdn) || peer.ip : key.slice(0, 12) + "…";
|
||||
};
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<SectionGroup title={t("settings.fileSharing.section")}>
|
||||
<HelpText>{error ?? t("settings.fileSharing.loading")}</HelpText>
|
||||
</SectionGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const rules = Object.entries(settings.peerRules ?? {});
|
||||
|
||||
return (
|
||||
<SectionGroup title={t("settings.fileSharing.section")}>
|
||||
{error && <HelpText className={"text-red-400"}>{error}</HelpText>}
|
||||
<div>
|
||||
<Label>{t("settings.fileSharing.mode.label")}</Label>
|
||||
<HelpText>{t("settings.fileSharing.mode.help")}</HelpText>
|
||||
<div
|
||||
role={"radiogroup"}
|
||||
aria-label={t("settings.fileSharing.mode.label")}
|
||||
className={"mt-2 flex flex-col gap-1.5"}
|
||||
>
|
||||
{MODES.map((mode) => {
|
||||
const active = settings.mode === mode.value;
|
||||
return (
|
||||
<button
|
||||
key={mode.value}
|
||||
type={"button"}
|
||||
role={"radio"}
|
||||
aria-checked={active}
|
||||
onClick={() =>
|
||||
void apply(
|
||||
new FileDropSettings({ ...settings, mode: mode.value }),
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border px-4 py-3 text-left",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
active
|
||||
? "border-netbird/60 bg-netbird/5"
|
||||
: "border-nb-gray-900 hover:border-nb-gray-800",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full border",
|
||||
active ? "border-netbird bg-netbird" : "border-nb-gray-600",
|
||||
)}
|
||||
>
|
||||
{active && <CheckIcon size={11} className={"text-white"} />}
|
||||
</span>
|
||||
<span className={"flex flex-col leading-tight"}>
|
||||
<span className={"text-sm text-nb-gray-100"}>
|
||||
{t(mode.labelKey)}
|
||||
</span>
|
||||
<span className={"text-xs text-nb-gray-400"}>
|
||||
{t(mode.helpKey)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("settings.fileSharing.destination.label")}</Label>
|
||||
<HelpText>{t("settings.fileSharing.destination.help")}</HelpText>
|
||||
<div className={"mt-2 flex items-center gap-3"}>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate rounded-md border border-nb-gray-900",
|
||||
"px-3 py-2 font-mono text-xs text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
{settings.destinationDir || t("settings.fileSharing.destination.unset")}
|
||||
</span>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
onClick={() => void changeDirectory()}
|
||||
>
|
||||
{t("settings.fileSharing.destination.change")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("settings.fileSharing.exceptions.label")}</Label>
|
||||
<HelpText>{t("settings.fileSharing.exceptions.help")}</HelpText>
|
||||
{rules.length === 0 ? (
|
||||
<HelpText className={"mt-2"}>
|
||||
{t("settings.fileSharing.exceptions.empty")}
|
||||
</HelpText>
|
||||
) : (
|
||||
<ul className={"mt-2 flex flex-col divide-y divide-nb-gray-920"}>
|
||||
{rules.map(([key, rule]) => (
|
||||
<li key={key} className={"flex items-center gap-3 py-2"}>
|
||||
{rule === PeerRule.Block ? (
|
||||
<BanIcon
|
||||
size={14}
|
||||
className={"shrink-0 text-red-400"}
|
||||
aria-hidden={"true"}
|
||||
/>
|
||||
) : (
|
||||
<CheckIcon
|
||||
size={14}
|
||||
className={"shrink-0 text-green-400"}
|
||||
aria-hidden={"true"}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={"min-w-0 flex-1 truncate text-sm text-nb-gray-100"}
|
||||
>
|
||||
{peerName(key)}
|
||||
</span>
|
||||
<span className={"shrink-0 text-xs text-nb-gray-400"}>
|
||||
{rule === PeerRule.Block
|
||||
? t("settings.fileSharing.exceptions.blocked")
|
||||
: t("settings.fileSharing.exceptions.always")}
|
||||
</span>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={t("settings.fileSharing.exceptions.remove")}
|
||||
onClick={() => void removeRule(key)}
|
||||
className={cn(
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
|
||||
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
)}
|
||||
>
|
||||
<XIcon size={13} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SectionGroup>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<VerticalTabs.Trigger
|
||||
value={"fileSharing"}
|
||||
icon={FolderDownIcon}
|
||||
title={t("settings.tabs.fileSharing")}
|
||||
/>
|
||||
{!features.disableProfiles && (
|
||||
<VerticalTabs.Trigger
|
||||
value={"profiles"}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
|
||||
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
|
||||
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
|
||||
import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx";
|
||||
import { SettingsFileSharing } from "@/modules/settings/SettingsFileSharing.tsx";
|
||||
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
|
||||
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
|
||||
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
||||
@@ -24,6 +25,7 @@ const enum Tab {
|
||||
General = "general",
|
||||
Network = "network",
|
||||
Security = "security",
|
||||
FileSharing = "fileSharing",
|
||||
Profiles = "profiles",
|
||||
SSH = "ssh",
|
||||
Advanced = "advanced",
|
||||
@@ -35,6 +37,7 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
|
||||
[Tab.General]: <SettingsGeneral />,
|
||||
[Tab.Network]: <SettingsNetwork />,
|
||||
[Tab.Security]: <SettingsSecurity />,
|
||||
[Tab.FileSharing]: <SettingsFileSharing />,
|
||||
[Tab.Profiles]: <ProfilesTab />,
|
||||
[Tab.SSH]: <SettingsSSH />,
|
||||
[Tab.Advanced]: <SettingsAdvanced />,
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "常に承認"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Всегда принимать"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "始终接受"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
238
client/ui/services/filedrop.go
Normal file
238
client/ui/services/filedrop.go
Normal file
@@ -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()),
|
||||
}
|
||||
}
|
||||
16
client/ui/services/openfile_other.go
Normal file
16
client/ui/services/openfile_other.go
Normal file
@@ -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()
|
||||
}
|
||||
9
client/ui/services/openfile_windows.go
Normal file
9
client/ui/services/openfile_windows.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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() })
|
||||
|
||||
@@ -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"),
|
||||
|
||||
146
client/ui/tray_filedrop.go
Normal file
146
client/ui/tray_filedrop.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
"google.golang.org/grpc/connectivity"
|
||||
|
||||
nbgrpc "github.com/netbirdio/netbird/client/grpc"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/encryption"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -62,6 +64,13 @@ type GrpcClient struct {
|
||||
connStateCallbackLock sync.RWMutex
|
||||
serverURL string
|
||||
|
||||
// netState gates the stream retry loop on OS-reported network
|
||||
// availability; nil (the default) disables gating.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper cuts the transport connections on network change; nil disables it.
|
||||
sweeper *netsweep.Sweeper
|
||||
|
||||
// syncStreamErr holds the last Sync stream error, or nil while the stream
|
||||
// is established and healthy. GetServerKey succeeds even when the peer
|
||||
// cannot sync (e.g. the server returns "settings not found"), so the
|
||||
@@ -111,16 +120,43 @@ func MaxRecvMsgSize() int {
|
||||
return size
|
||||
}
|
||||
|
||||
// Option configures optional GrpcClient behavior.
|
||||
type Option func(*GrpcClient)
|
||||
|
||||
// WithNetworkState injects the OS network availability state that gates the
|
||||
// stream retry loop; without it gating is disabled.
|
||||
func WithNetworkState(netState *netstate.State) Option {
|
||||
return func(c *GrpcClient) { c.netState = netState }
|
||||
}
|
||||
|
||||
// WithSweeper injects the network change sweeper.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) Option {
|
||||
return func(c *GrpcClient) { c.sweeper = sweeper }
|
||||
}
|
||||
|
||||
// NewClient creates a new client to Management service
|
||||
func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsEnabled bool) (*GrpcClient, error) {
|
||||
var conn *grpc.ClientConn
|
||||
func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsEnabled bool, opts ...Option) (*GrpcClient, error) {
|
||||
// Options apply before dialing: the sweeper must wrap the first connection too.
|
||||
c := &GrpcClient{
|
||||
key: ourPrivateKey,
|
||||
ctx: ctx,
|
||||
connStateCallbackLock: sync.RWMutex{},
|
||||
serverURL: addr,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
|
||||
var extraOpts []grpc.DialOption
|
||||
if maxSize := MaxRecvMsgSize(); maxSize > 0 {
|
||||
extraOpts = append(extraOpts, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxSize)))
|
||||
log.Infof("management gRPC max receive message size set to %d bytes", maxSize)
|
||||
}
|
||||
if c.sweeper != nil {
|
||||
extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.sweeper))
|
||||
}
|
||||
|
||||
var conn *grpc.ClientConn
|
||||
operation := func() error {
|
||||
var err error
|
||||
conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.ManagementComponent, extraOpts...)
|
||||
@@ -136,16 +172,9 @@ func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsE
|
||||
return nil, err
|
||||
}
|
||||
|
||||
realClient := proto.NewManagementServiceClient(conn)
|
||||
|
||||
return &GrpcClient{
|
||||
key: ourPrivateKey,
|
||||
realClient: realClient,
|
||||
ctx: ctx,
|
||||
conn: conn,
|
||||
connStateCallbackLock: sync.RWMutex{},
|
||||
serverURL: addr,
|
||||
}, nil
|
||||
c.conn = conn
|
||||
c.realClient = proto.NewManagementServiceClient(conn)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// GetServerURL returns the management server URL
|
||||
@@ -206,16 +235,33 @@ func (c *GrpcClient) withMgmtStream(
|
||||
ctx context.Context,
|
||||
handler func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error,
|
||||
) error {
|
||||
backOff := defaultBackoff(ctx)
|
||||
backOff := c.sweeper.QuickRetryBackoff(ctx, defaultBackoff(ctx), c.netState)
|
||||
operation := func() error {
|
||||
log.Debugf("management connection state %v", c.conn.GetState())
|
||||
connState := c.conn.GetState()
|
||||
// suspend reconnect attempts while the OS reports no usable network.
|
||||
// Wait only errors on a cancelled context, which means shutdown, so
|
||||
// stop the loop without reporting a failure.
|
||||
if waited, err := c.netState.Wait(ctx); err != nil {
|
||||
log.Debugf("management connection context has been canceled while offline, this usually indicates shutdown")
|
||||
return nil //nolint:nilerr // a cancelled context means shutdown, not a retryable failure
|
||||
} else if waited {
|
||||
backOff.Reset()
|
||||
}
|
||||
|
||||
connState := c.conn.GetState()
|
||||
log.Debugf("management connection state %v", connState)
|
||||
if connState == connectivity.Shutdown {
|
||||
return backoff.Permanent(fmt.Errorf("connection to management has been shut down"))
|
||||
} else if !(connState == connectivity.Ready || connState == connectivity.Idle) {
|
||||
}
|
||||
if !(connState == connectivity.Ready || connState == connectivity.Idle) {
|
||||
// A dial may already be in flight (e.g. the other stream triggered
|
||||
// it after a network change); wait for it to settle and proceed if
|
||||
// the channel became usable, instead of burning a backoff round on
|
||||
// a successful dial. A failed dial errors out as before.
|
||||
c.conn.WaitForStateChange(ctx, connState)
|
||||
return fmt.Errorf("connection to management is not ready and in %s state", connState)
|
||||
connState = c.conn.GetState()
|
||||
if !(connState == connectivity.Ready || connState == connectivity.Idle) {
|
||||
return fmt.Errorf("connection to management is not ready and in %s state", connState)
|
||||
}
|
||||
}
|
||||
|
||||
serverPubKey, err := c.getServerPublicKey()
|
||||
@@ -227,7 +273,7 @@ func (c *GrpcClient) withMgmtStream(
|
||||
return handler(ctx, *serverPubKey, backOff)
|
||||
}
|
||||
|
||||
err := backoff.Retry(operation, backOff)
|
||||
err := nbgrpc.Retry(ctx, operation, backOff, c.netState)
|
||||
if err != nil {
|
||||
log.Warnf("exiting the Management service connection retry loop due to the unrecoverable error: %s", err)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
||||
"github.com/netbirdio/netbird/shared/relay/client/dialer"
|
||||
netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
|
||||
@@ -184,6 +185,10 @@ type Client struct {
|
||||
// datagram-sized transport is avoided on subsequent connects. Shared via
|
||||
// the manager.
|
||||
transportFallback *transportFallback
|
||||
|
||||
// sweeper cuts the relay connection on network change; the read loop
|
||||
// reports the disconnect and the guard reconnects. Shared via the manager.
|
||||
sweeper *netsweep.Sweeper
|
||||
// datagramFallbackTriggered guards a single fallback per connection so a
|
||||
// burst of oversized datagrams triggers one reconnect, not many.
|
||||
datagramFallbackTriggered atomic.Bool
|
||||
@@ -393,6 +398,12 @@ func (c *Client) Close() error {
|
||||
}
|
||||
|
||||
func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
|
||||
// A sweep cancels this context, so a dial started on the old network
|
||||
// aborts instead of waiting out its handshake timeout.
|
||||
dial := c.sweeper.StartDial(ctx)
|
||||
defer dial.Release()
|
||||
ctx = dial.Ctx()
|
||||
|
||||
mode := transportModeFromEnv()
|
||||
dialers := c.getDialers(mode)
|
||||
|
||||
@@ -417,12 +428,19 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
|
||||
return nil, fmt.Errorf("dial via FQDN: %w", err)
|
||||
}
|
||||
}
|
||||
c.relayConn = conn
|
||||
c.datagramFallbackTriggered.Store(false)
|
||||
// Read the transport off the concrete connection: the sweeper's wrapper
|
||||
// embeds net.Conn only, so it does not promote Protocol().
|
||||
if tc, ok := conn.(transportConn); ok {
|
||||
c.transport = tc.Protocol()
|
||||
}
|
||||
|
||||
conn, err := dial.WrapConn(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("register connection: %w", err)
|
||||
}
|
||||
c.relayConn = conn
|
||||
c.datagramFallbackTriggered.Store(false)
|
||||
|
||||
instanceURL, err := c.handShake(ctx)
|
||||
if err != nil {
|
||||
cErr := conn.Close()
|
||||
|
||||
@@ -7,9 +7,22 @@ import (
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
const defaultMaxBackoffInterval = 60 * time.Second
|
||||
const (
|
||||
defaultMaxBackoffInterval = 60 * time.Second
|
||||
|
||||
// quickReconnectBudget bounds how long a quick reconnect waits for the
|
||||
// network before handing the retry over to the ticker.
|
||||
quickReconnectBudget = 1500 * time.Millisecond
|
||||
|
||||
// verdictSettleWindow is how long an online verdict must hold before it
|
||||
// is trusted: the disconnect often precedes the OS offline flag by a few
|
||||
// milliseconds.
|
||||
verdictSettleWindow = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
// Guard manage the reconnection tries to the Relay server in case of disconnection event.
|
||||
type Guard struct {
|
||||
@@ -22,14 +35,19 @@ type Guard struct {
|
||||
// attempts.
|
||||
maxBackoffInterval time.Duration
|
||||
|
||||
// netState gates reconnect attempts on OS-reported network availability;
|
||||
// nil disables gating.
|
||||
netState *netstate.State
|
||||
|
||||
// lastErr is the error from the most recent failed reconnect attempt,
|
||||
// surfaced as the home relay status while disconnected.
|
||||
lastErr atomic.Pointer[error]
|
||||
}
|
||||
|
||||
// NewGuard creates a new guard for the relay client. A non-positive
|
||||
// maxBackoffInterval falls back to defaultMaxBackoffInterval.
|
||||
func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration) *Guard {
|
||||
// maxBackoffInterval falls back to defaultMaxBackoffInterval. A nil netState
|
||||
// disables network availability gating.
|
||||
func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration, netState *netstate.State) *Guard {
|
||||
if maxBackoffInterval <= 0 {
|
||||
maxBackoffInterval = defaultMaxBackoffInterval
|
||||
}
|
||||
@@ -38,6 +56,7 @@ func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration) *Guard {
|
||||
OnReconnected: make(chan struct{}, 1),
|
||||
serverPicker: sp,
|
||||
maxBackoffInterval: maxBackoffInterval,
|
||||
netState: netState,
|
||||
}
|
||||
return g
|
||||
}
|
||||
@@ -70,11 +89,21 @@ func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) {
|
||||
|
||||
// start a ticker to pick a new server
|
||||
ticker := g.exponentTicker(ctx)
|
||||
defer ticker.Stop()
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// suspend reconnect attempts while the OS reports no usable network
|
||||
if waited, err := g.netState.Wait(ctx); err != nil {
|
||||
return
|
||||
} else if waited {
|
||||
ticker.Stop()
|
||||
ticker = g.exponentTicker(ctx)
|
||||
continue
|
||||
}
|
||||
if err := g.retry(ctx); err != nil {
|
||||
log.Errorf("failed to pick new Relay server: %s", err)
|
||||
g.setLastError(err)
|
||||
@@ -100,7 +129,12 @@ func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool
|
||||
return false
|
||||
}
|
||||
|
||||
if cancelled := waiteBeforeRetry(parentCtx); !cancelled {
|
||||
if ok := g.waitForNetwork(parentCtx); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Still offline after the budget: leave the retry to the ticker.
|
||||
if !g.netState.IsOnline() {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -166,14 +200,40 @@ func (g *Guard) exponentTicker(ctx context.Context) *backoff.Ticker {
|
||||
return backoff.NewTicker(bo)
|
||||
}
|
||||
|
||||
func waiteBeforeRetry(ctx context.Context) bool {
|
||||
timer := time.NewTimer(1500 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
// waitForNetwork waits out the settle window while online, or waits for the
|
||||
// network to return while offline, within the budget. Returns false when ctx
|
||||
// is cancelled. Without an injected netState it degrades to a fixed
|
||||
// budget-long sleep, the pre-netstate behavior.
|
||||
func (g *Guard) waitForNetwork(ctx context.Context) bool {
|
||||
budget := time.NewTimer(quickReconnectBudget)
|
||||
defer budget.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
settleWindow := verdictSettleWindow
|
||||
if g.netState == nil {
|
||||
settleWindow = quickReconnectBudget
|
||||
}
|
||||
settle := time.NewTimer(settleWindow)
|
||||
defer settle.Stop()
|
||||
|
||||
for {
|
||||
// Channel first, flag second: a flip in between still fires the channel.
|
||||
changedCh := g.netState.Changed()
|
||||
if g.netState.IsOnline() {
|
||||
select {
|
||||
case <-settle.C:
|
||||
return true
|
||||
case <-changedCh:
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-budget.C:
|
||||
return true
|
||||
case <-changedCh:
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
relayAuth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
||||
)
|
||||
|
||||
@@ -65,6 +67,17 @@ func WithMaxBackoffInterval(d time.Duration) ManagerOption {
|
||||
return func(m *Manager) { m.maxBackoffInterval = d }
|
||||
}
|
||||
|
||||
// WithNetworkState injects the OS network availability state that gates the
|
||||
// reconnect guard; without it reconnect attempts are not gated.
|
||||
func WithNetworkState(netState *netstate.State) ManagerOption {
|
||||
return func(m *Manager) { m.netState = netState }
|
||||
}
|
||||
|
||||
// WithSweeper injects the network change sweeper.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) ManagerOption {
|
||||
return func(m *Manager) { m.sweeper = sweeper }
|
||||
}
|
||||
|
||||
// Manager is a manager for the relay client instances. It establishes one persistent connection to the given relay URL
|
||||
// and automatically reconnect to them in case disconnection.
|
||||
// The manager also manage temporary relay connection. If a client wants to communicate with a client on a
|
||||
@@ -92,6 +105,8 @@ type Manager struct {
|
||||
|
||||
mtu uint16
|
||||
maxBackoffInterval time.Duration
|
||||
netState *netstate.State
|
||||
sweeper *netsweep.Sweeper
|
||||
|
||||
cleanupInterval time.Duration
|
||||
keepUnusedServerTime time.Duration
|
||||
@@ -128,8 +143,9 @@ func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uin
|
||||
for _, opt := range opts {
|
||||
opt(m)
|
||||
}
|
||||
m.serverPicker.Sweeper = m.sweeper
|
||||
m.serverPicker.ServerURLs.Store(serverURLs)
|
||||
m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval)
|
||||
m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval, m.netState)
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -354,6 +370,7 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string
|
||||
|
||||
relayClient := NewClientWithServerIP(serverAddress, serverIP, m.tokenStore, m.peerID, m.mtu)
|
||||
relayClient.SetTransportFallback(m.transportFallback)
|
||||
relayClient.sweeper = m.sweeper
|
||||
err := relayClient.Connect(m.ctx)
|
||||
if err != nil {
|
||||
rt.Lock()
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
||||
)
|
||||
|
||||
@@ -30,6 +31,7 @@ type ServerPicker struct {
|
||||
MTU uint16
|
||||
ConnectionTimeout time.Duration
|
||||
TransportFallback *transportFallback
|
||||
Sweeper *netsweep.Sweeper
|
||||
}
|
||||
|
||||
func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
|
||||
@@ -73,6 +75,7 @@ func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan con
|
||||
log.Infof("try to connecting to relay server: %s", url)
|
||||
relayClient := NewClient(url, sp.TokenStore, sp.PeerID, sp.MTU)
|
||||
relayClient.SetTransportFallback(sp.TransportFallback)
|
||||
relayClient.sweeper = sp.Sweeper
|
||||
err := relayClient.Connect(ctx)
|
||||
resultChan <- connResult{
|
||||
RelayClient: relayClient,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
nbgrpc "github.com/netbirdio/netbird/client/grpc"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/encryption"
|
||||
"github.com/netbirdio/netbird/shared/management/client"
|
||||
"github.com/netbirdio/netbird/shared/signal/proto"
|
||||
@@ -65,6 +67,13 @@ type GrpcClient struct {
|
||||
connStateCallback ConnStateNotifier
|
||||
connStateCallbackLock sync.RWMutex
|
||||
|
||||
// netState gates the Receive retry loop on OS-reported network
|
||||
// availability; nil (the default) disables gating.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper cuts the transport connections on network change; nil disables it.
|
||||
sweeper *netsweep.Sweeper
|
||||
|
||||
onReconnectedListenerFn func()
|
||||
|
||||
decryptionWorker *Worker
|
||||
@@ -88,13 +97,43 @@ type GrpcClient struct {
|
||||
watchdogWg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewClient creates a new Signal client
|
||||
func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled bool) (*GrpcClient, error) {
|
||||
var conn *grpc.ClientConn
|
||||
// Option configures optional GrpcClient behavior.
|
||||
type Option func(*GrpcClient)
|
||||
|
||||
// WithNetworkState injects the OS network availability state that gates the
|
||||
// Receive retry loop; without it gating is disabled.
|
||||
func WithNetworkState(netState *netstate.State) Option {
|
||||
return func(c *GrpcClient) { c.netState = netState }
|
||||
}
|
||||
|
||||
// WithSweeper injects the network change sweeper.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) Option {
|
||||
return func(c *GrpcClient) { c.sweeper = sweeper }
|
||||
}
|
||||
|
||||
// NewClient creates a new Signal client
|
||||
func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled bool, opts ...Option) (*GrpcClient, error) {
|
||||
// Options apply before dialing: the sweeper must wrap the first connection too.
|
||||
c := &GrpcClient{
|
||||
ctx: ctx,
|
||||
key: key,
|
||||
mux: sync.Mutex{},
|
||||
status: StreamDisconnected,
|
||||
connStateCallbackLock: sync.RWMutex{},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
|
||||
var extraOpts []grpc.DialOption
|
||||
if c.sweeper != nil {
|
||||
extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.sweeper))
|
||||
}
|
||||
|
||||
var conn *grpc.ClientConn
|
||||
operation := func() error {
|
||||
var err error
|
||||
conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.SignalComponent)
|
||||
conn, err = nbgrpc.CreateConnection(ctx, addr, tlsEnabled, wsproxy.SignalComponent, extraOpts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create connection: %w", err)
|
||||
}
|
||||
@@ -109,15 +148,9 @@ func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled boo
|
||||
|
||||
log.Debugf("connected to Signal Service: %v", conn.Target())
|
||||
|
||||
return &GrpcClient{
|
||||
realClient: proto.NewSignalExchangeClient(conn),
|
||||
ctx: ctx,
|
||||
signalConn: conn,
|
||||
key: key,
|
||||
mux: sync.Mutex{},
|
||||
status: StreamDisconnected,
|
||||
connStateCallbackLock: sync.RWMutex{},
|
||||
}, nil
|
||||
c.signalConn = conn
|
||||
c.realClient = proto.NewSignalExchangeClient(conn)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *GrpcClient) StreamConnected() bool {
|
||||
@@ -165,19 +198,36 @@ func defaultBackoff(ctx context.Context) backoff.BackOff {
|
||||
// The connection retry logic will try to reconnect for 30 min and if wasn't successful will propagate the error to the function caller.
|
||||
func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Message) error) error {
|
||||
|
||||
var backOff = defaultBackoff(ctx)
|
||||
backOff := c.sweeper.QuickRetryBackoff(ctx, defaultBackoff(ctx), c.netState)
|
||||
|
||||
operation := func() error {
|
||||
// suspend reconnect attempts while the OS reports no usable network.
|
||||
// Wait only errors on a cancelled context, which means shutdown, so
|
||||
// stop the loop without reporting a failure.
|
||||
if waited, err := c.netState.Wait(ctx); err != nil {
|
||||
log.Debugf("signal connection context has been canceled while offline, this usually indicates shutdown")
|
||||
return nil
|
||||
} else if waited {
|
||||
backOff.Reset()
|
||||
}
|
||||
|
||||
c.notifyStreamDisconnected()
|
||||
|
||||
log.Debugf("signal connection state %v", c.signalConn.GetState())
|
||||
connState := c.signalConn.GetState()
|
||||
log.Debugf("signal connection state %v", connState)
|
||||
if connState == connectivity.Shutdown {
|
||||
return backoff.Permanent(fmt.Errorf("connection to signal has been shut down"))
|
||||
} else if !(connState == connectivity.Ready || connState == connectivity.Idle) {
|
||||
}
|
||||
if !(connState == connectivity.Ready || connState == connectivity.Idle) {
|
||||
// A dial may already be in flight (e.g. triggered by another RPC
|
||||
// after a network change); wait for it to settle and proceed if
|
||||
// the channel became usable, instead of burning a backoff round on
|
||||
// a successful dial. A failed dial errors out as before.
|
||||
c.signalConn.WaitForStateChange(ctx, connState)
|
||||
return fmt.Errorf("connection to signal is not ready and in %s state", connState)
|
||||
connState = c.signalConn.GetState()
|
||||
if !(connState == connectivity.Ready || connState == connectivity.Idle) {
|
||||
return fmt.Errorf("connection to signal is not ready and in %s state", connState)
|
||||
}
|
||||
}
|
||||
|
||||
// connect to Signal stream identifying ourselves with a public WireGuard key
|
||||
@@ -231,7 +281,7 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes
|
||||
return nil
|
||||
}
|
||||
|
||||
err := backoff.Retry(operation, backOff)
|
||||
err := nbgrpc.Retry(ctx, operation, backOff, c.netState)
|
||||
if err != nil {
|
||||
log.Errorf("exiting the Signal service connection retry loop due to the unrecoverable error: %v", err)
|
||||
return err
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user