Compare commits

..

2 Commits

Author SHA1 Message Date
Zoltán Papp
9e9e33ae68 [client] Reduce cognitive complexity of Server.Login
Login sat at cognitive complexity 27, over the 25 the linter allows.

Extract the interactive SSO branch into startSSOLogin, and split the
nested in-flight-flow reuse check out of it into reuseOAuthFlow, which
flattens the original if/else into early returns: it returns the cached
auth info when the previous flow targets the same client and still has
more than 90s left, otherwise cancels the stale wait and returns nil so
the caller requests a fresh flow.

The helpers take the contextState through a small statusSetter
interface, since internal.contextState is unexported and re-deriving it
with CtxGetState inside the helper would resolve against callerCtx
rather than rootCtx.

No behavior change: same ordering of state transitions, same mutex scope
around the oauthAuthFlow write, same error paths. Login is now at 21.
2026-08-17 10:12:42 +02:00
Zoltán Papp
0738734b6e [client] Force interactive login when extending the auth session
A session extend must be answered from the account the peer is registered
under. With a silent PKCE flow (DisablePromptLogin or max_age=0) the IdP
answers from whatever session it already holds, which need not be the
peer's account when several are signed in; the token then fails the
user match in ExtendAuthSession with no way to pick another account.

Mark the PKCE flow request as a session extend so the management server
can force prompt=login for it, overriding the configured silent flow.
2026-08-15 10:33:50 +02:00
127 changed files with 1548 additions and 15394 deletions

View File

@@ -26,8 +26,6 @@ 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"
@@ -42,6 +40,11 @@ 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
@@ -82,13 +85,6 @@ 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
@@ -114,11 +110,6 @@ 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) {
@@ -165,8 +156,6 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
recorder: peer.NewRecorder(""),
ctxCancelLock: &sync.Mutex{},
networkChangeListener: networkChangeListener,
netState: netstate.New(),
sweeper: netsweep.New(),
}
}
@@ -207,9 +196,7 @@ 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,
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
c.attachFileDrop(connectClient, cfgFile)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
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
@@ -250,9 +237,7 @@ 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,
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
c.attachFileDrop(connectClient, cfgFile)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
c.setState(cfg, cacheDir, cfgFile, connectClient)
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
}
@@ -300,24 +285,6 @@ 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
@@ -558,11 +525,7 @@ func (c *Client) OnUpdatedHostDNS(list *DNSList) error {
// SetConnectionListener set the network connection listener
func (c *Client) SetConnectionListener(listener ConnectionListener) {
if listener == nil {
c.recorder.RemoveConnectionListener()
return
}
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
c.recorder.SetConnectionListener(listener)
}
// RemoveConnectionListener remove connection listener

View File

@@ -1,78 +0,0 @@
//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)
}

View File

@@ -1,41 +0,0 @@
//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))
}

View File

@@ -1,205 +0,0 @@
//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)
}
}

View File

@@ -1,122 +0,0 @@
//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()
}

View File

@@ -1,261 +0,0 @@
//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)
}
}

View File

@@ -1,163 +0,0 @@
//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()
}

View File

@@ -191,49 +191,48 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
return nil
}
// loginHintSetter is implemented by both concrete flows (PKCE and device code)
// but absent from the OAuthFlow interface, hence the assertion below — the same
// way internal/auth wires it in authenticateWithPKCEFlow.
type loginHintSetter interface {
SetLoginHint(hint string)
}
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, profileLoginHint(a.cfgPath))
return a.foregroundGetTokenInfoFlow(authClient, urlOpener, isAndroidTV, false)
}
// foregroundGetTokenInfoFlow runs the interactive flow. sessionExtend tells the
// server the token will renew this peer's session rather than log a peer in, so
// it can rule out a silent authorization the IdP could answer from an unrelated
// account. See PKCEAuthorizationFlowRequest.
func (a *Auth) foregroundGetTokenInfoFlow(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool, sessionExtend bool) (*auth.TokenInfo, error) {
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, sessionExtend)
if err != nil {
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
}
return runOAuthFlow(a.ctx, oAuthFlow, urlOpener, nil)
}
// profileLoginHint returns the stored account email for the profile at cfgPath.
// An empty hint is deliberate, not a fallback: a fresh profile leaves the
// choice to the IdP. Switching accounts is done by switching or removing
// profiles, not by logging out — logout keeps the email.
func profileLoginHint(cfgPath string) string {
if cfgPath == "" {
return ""
// An empty hint is deliberate, not a fallback: a fresh profile leaves the
// choice to the IdP. Switching accounts is done by switching or removing
// profiles, not by logging out — logout keeps the email.
if a.cfgPath != "" {
if hint := readProfileEmail(a.cfgPath); hint != "" {
if setter, ok := oAuthFlow.(loginHintSetter); ok {
setter.SetLoginHint(hint)
}
}
}
return readProfileEmail(cfgPath)
}
// runOAuthFlow drives an already acquired OAuth flow to a token: requests the
// flow info, presents the verification URL through the opener and waits for
// the browser round-trip. Open is called synchronously — it is what marks the
// surface as opened on the client side, and a fast token's OnLoginSuccess is
// a no-op until it has, so the dismissal would be dropped rather than
// delayed. Openers must therefore not block: they post their UI work and
// return. onWaiting, when set, runs after the URL is shown, right before the
// blocking wait.
func runOAuthFlow(ctx context.Context, flow auth.OAuthFlow, urlOpener URLOpener, onWaiting func()) (*auth.TokenInfo, error) {
flowInfo, err := flow.RequestAuthInfo(ctx)
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
if err != nil {
return nil, fmt.Errorf("request auth info: %w", err)
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
}
urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
if onWaiting != nil {
onWaiting()
}
tokenInfo, err := flow.WaitToken(ctx, flowInfo)
tokenInfo, err := oAuthFlow.WaitToken(a.ctx, flowInfo)
if err != nil {
return nil, fmt.Errorf("wait for token: %w", err)
return nil, fmt.Errorf("waiting for browser login failed: %v", err)
}
return &tokenInfo, nil

View File

@@ -1,38 +0,0 @@
//go:build android
package android
import (
"fmt"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
type prefsStore interface {
Get(namespace string, v any) (bool, error)
Put(namespace string, v any) error
}
type profilePrefs struct {
prefs *profilemanager.Prefs
}
func newProfilePrefs(configDir, profileID string) (*profilePrefs, error) {
if configDir == "" || profileID == "" {
return nil, fmt.Errorf("profile prefs require a config dir and profile ID")
}
pm := NewProfileManager(configDir)
prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(profileID), androidUsername)
if err != nil {
return nil, fmt.Errorf("resolve profile prefs: %w", err)
}
return &profilePrefs{prefs: prefs}, nil
}
func (p *profilePrefs) Get(namespace string, v any) (bool, error) {
return p.prefs.Get(namespace, v)
}
func (p *profilePrefs) Put(namespace string, v any) error {
return p.prefs.Put(namespace, v)
}

View File

@@ -48,33 +48,6 @@ 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.

View File

@@ -293,11 +293,13 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
}
defer authClient.Close()
// Passing the config path makes the flow pick up the login_hint: an extend
// renews the session of the account already signed in, so it must not stop to
// offer a choice.
// Passing the config path makes the flow pick up the login_hint. That alone
// cannot keep the IdP on this profile's account though — a hint is only a
// suggestion, and a silent authorization is answered from whatever session the
// IdP already has, which need not be this peer's when several accounts are
// signed in. Marking the flow as an extend lets the server rule that out.
a := NewAuthWithConfig(ctx, cfg, cfgPath)
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
tokenInfo, err := a.foregroundGetTokenInfoFlow(authClient, urlOpener, isAndroidTV, true)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)
}

View File

@@ -1,649 +0,0 @@
//go:build android
package android
import (
"context"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
gossh "golang.org/x/crypto/ssh"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
nbssh "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/client/ssh/detection"
)
const (
sshDialTimeout = 30 * time.Second
sshDetectionTimeout = 5 * time.Second
)
// PasswordRequiredMarker tells Java to prompt for a password and retry. It is
// a string because gomobile flattens errors to their message, so a sentinel
// value would not survive the binding.
const PasswordRequiredMarker = "netbird-ssh-password-required"
// HostKeyUnknownMarker tells Java to show the fingerprint and, on confirmation,
// retry with TrustHostKey set. The presented fingerprint is appended after the
// marker so the prompt can display it and the retry can guard against a key
// that changed between the two connects. Only regular (non-NetBird) servers
// reach this: NetBird peers verify against the registry.
const HostKeyUnknownMarker = "netbird-ssh-hostkey-unknown"
var (
errPasswordRequired = errors.New(PasswordRequiredMarker)
errClientClosed = errors.New("ssh client closed")
)
// errHostKeyUnknown carries the presented fingerprint so Connect can build the
// marker message the Java side parses.
type errHostKeyUnknown struct {
fingerprint string
}
func (e *errHostKeyUnknown) Error() string {
return HostKeyUnknownMarker + ":" + e.fingerprint
}
// SSHTerminalListener receives SSH session events. It is implemented in Java.
//
// All callbacks are invoked from goroutines and may run concurrently with each
// other; the implementation must be safe to call from any thread.
type SSHTerminalListener interface {
OnConnected()
OnData(data []byte)
OnClose(reason string)
OnError(message string)
}
// SSHClient is a NetBird-aware SSH client exposed to Java via gomobile.
//
// It dials through the running NetBird tunnel and runs a standard SSH session
// on top with PTY enabled. Host-key verification uses the NetBird-provided
// peer SSH host keys, identical to the desktop client.
type SSHClient struct {
nb *Client
mu sync.Mutex
listener SSHTerminalListener
urlOpener URLOpener
sshClient *gossh.Client
session *gossh.Session
stdin io.WriteCloser
closed bool
// gen identifies the current connection attempt. Connect and Close bump it,
// so an in-flight dial or a reader left over from a previous connection
// finds itself stale and stays silent instead of publishing OnConnected or
// OnClose for a connection the caller already abandoned.
gen uint64
dialCancel context.CancelFunc
// knownHostsConfigDir and knownHostsProfile locate the TOFU store for
// regular SSH servers in the profile's preferences. Java supplies them,
// since an overlay IP is a different host under a different profile. Empty
// until set: without them a regular server cannot be verified and Connect
// refuses one.
knownHostsConfigDir string
knownHostsProfile string
// trustHostKey carries the fingerprint the user confirmed on a previous
// attempt, so the retry accepts exactly that key and persists it.
trustHostKey string
}
// NewSSHClient creates a new SSH client bound to the running NetBird Client.
func NewSSHClient(c *Client) *SSHClient {
return &SSHClient{nb: c}
}
// SetListener registers the Java listener. Must be called before Connect to
// receive any events.
func (s *SSHClient) SetListener(l SSHTerminalListener) {
s.mu.Lock()
s.listener = l
s.mu.Unlock()
}
// SetURLOpener registers the Java URL opener used to display the device-code
// authorization page in a Custom Tabs window when the target peer requires
// JWT authentication. Must be set before Connect to be effective.
func (s *SSHClient) SetURLOpener(opener URLOpener) {
s.mu.Lock()
s.urlOpener = opener
s.mu.Unlock()
}
// SetKnownHostsStore points the TOFU host-key store at a profile's preferences.
// Must be set before connecting to a regular SSH server; without it such a
// server cannot be verified and Connect refuses one.
func (s *SSHClient) SetKnownHostsStore(configDir, profileID string) {
s.mu.Lock()
s.knownHostsConfigDir = configDir
s.knownHostsProfile = profileID
s.mu.Unlock()
}
// TrustHostKey records the fingerprint the user confirmed for a regular server,
// so the next Connect accepts that exact key and adds it to the known-hosts
// store. Passing a fingerprint that no longer matches makes the connect fail
// rather than trust a key that changed since the prompt.
func (s *SSHClient) TrustHostKey(fingerprint string) {
s.mu.Lock()
s.trustHostKey = fingerprint
s.mu.Unlock()
}
// Connect dials the SSH server through the NetBird tunnel and performs the
// SSH handshake. It auto-detects the server type via SSH banner inspection
// and selects the appropriate authentication path:
//
// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code
// flow, opens the verification URL through the registered URLOpener, and
// uses the resulting token as the SSH password. Host-key verification
// uses the NetBird peer registry.
// - NetBird-SSH server without JWT: authenticates with the NetBird SSH
// private key. Host-key verification uses the NetBird peer registry.
// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key
// first (so a user-installed NetBird public key works), then falls back
// to the supplied password if non-empty. Host-key verification is
// trust-on-first-use against the per-profile known-hosts store.
//
// The password parameter is only consulted for regular SSH servers.
func (s *SSHClient) Connect(host string, port int, user, password string) error {
if port < 1 || port > 65535 {
return fmt.Errorf("invalid port: %d", port)
}
cfg, cfgPath, cc := s.nb.authSnapshot()
if cc == nil {
return errors.New("netbird client not running")
}
if cfg == nil {
return errors.New("netbird config not loaded")
}
engine := cc.Engine()
if engine == nil {
return errors.New("netbird engine not available")
}
s.mu.Lock()
s.gen++
gen := s.gen
s.mu.Unlock()
serverType := detectServerType(host, port)
log.Debugf("SSH server type: %s", serverType)
authMethods, hostKeyCallback, err := s.buildAuth(cfg, cfgPath, engine, serverType, password)
if err != nil {
return err
}
clientConfig := &gossh.ClientConfig{
User: user,
Auth: authMethods,
HostKeyCallback: hostKeyCallback,
Timeout: sshDialTimeout,
}
err = s.dialAndHandshake(gen, host, port, clientConfig)
// An unknown host key is a prompt, not a failure: return the marker intact
// (rootCause would unwrap it) so Java can show the fingerprint and retry.
var unknownHost *errHostKeyUnknown
if errors.As(err, &unknownHost) {
return errors.New(unknownHost.Error())
}
// A regular server may still accept a password, so let the caller ask for
// one instead of failing. NetBird servers never use a password, so a
// failure there is genuine.
if err != nil && serverType != detection.ServerTypeNetBirdJWT &&
serverType != detection.ServerTypeNetBirdNoJWT && isAuthFailure(err) &&
passwordCouldHelp(err, password != "") {
return errPasswordRequired
}
if err != nil {
return rootCause(err)
}
return nil
}
// StartSession requests a PTY and starts an interactive shell. Output from
// the session is forwarded to the listener via OnData.
func (s *SSHClient) StartSession(cols, rows int) error {
err := s.startSession(cols, rows)
if err != nil {
log.Infof("SSH: start session failed: %v", err)
return rootCause(err)
}
return nil
}
// Write sends data to the SSH session stdin.
func (s *SSHClient) Write(data []byte) error {
s.mu.Lock()
stdin := s.stdin
s.mu.Unlock()
if stdin == nil {
return errors.New("ssh session not started")
}
if _, err := stdin.Write(data); err != nil {
return fmt.Errorf("write stdin: %w", err)
}
return nil
}
// Resize updates the PTY window size.
func (s *SSHClient) Resize(cols, rows int) error {
s.mu.Lock()
session := s.session
s.mu.Unlock()
if session == nil {
return errors.New("ssh session not started")
}
return session.WindowChange(rows, cols)
}
// Reset makes a closed client usable for another Connect: Close leaves the
// one-shot guard set, and clearing it lets the same client back a reconnect.
func (s *SSHClient) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.closed = false
}
// Close terminates the SSH session and underlying connection. Safe to call
// multiple times.
func (s *SSHClient) Close() error {
s.mu.Lock()
s.gen++
if s.dialCancel != nil {
s.dialCancel()
s.dialCancel = nil
}
sshClient := s.sshClient
session := s.session
stdin := s.stdin
s.sshClient = nil
s.session = nil
s.stdin = nil
notify := !s.closed
s.closed = true
listener := s.listener
s.mu.Unlock()
if stdin != nil {
if err := stdin.Close(); err != nil {
log.Debugf("ssh: stdin close: %v", err)
}
}
if session != nil {
if err := session.Close(); err != nil && !errors.Is(err, io.EOF) {
log.Debugf("ssh: session close: %v", err)
}
}
var firstErr error
if sshClient != nil {
if err := sshClient.Close(); err != nil {
firstErr = err
}
}
if notify && listener != nil {
listener.OnClose("closed by client")
}
return firstErr
}
func (s *SSHClient) startSession(cols, rows int) error {
log.Debugf("SSH: starting session %dx%d", cols, rows)
s.mu.Lock()
sshClient := s.sshClient
gen := s.gen
s.mu.Unlock()
if sshClient == nil {
return errors.New("ssh client not connected")
}
pty, err := nbssh.StartPTYSession(sshClient, cols, rows)
if err != nil {
return err
}
s.mu.Lock()
if gen != s.gen {
s.mu.Unlock()
closeQuiet(pty.Session, "stale session")
return errClientClosed
}
s.session = pty.Session
s.stdin = pty.Stdin
s.mu.Unlock()
readerDone := make(chan string, 2)
go func() { readerDone <- s.readLoop(pty.Stdout, "stdout") }()
go func() { readerDone <- s.readLoop(pty.Stderr, "stderr") }()
go func() {
reason := <-readerDone
if second := <-readerDone; reason == "" {
reason = second
}
s.notifyClose(gen, reason)
}()
log.Debug("SSH: session started, shell running")
return nil
}
func (s *SSHClient) buildAuth(cfg *profilemanager.Config, cfgPath string, engine *internal.Engine,
serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) {
switch serverType {
case detection.ServerTypeNetBirdJWT:
token, err := s.requestJWTToken(cfg, cfgPath)
if err != nil {
return nil, nil, fmt.Errorf("jwt: %w", err)
}
auths := []gossh.AuthMethod{gossh.Password(token)}
return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil
case detection.ServerTypeNetBirdNoJWT:
if cfg.SSHKey == "" {
return nil, nil, errors.New("no NetBird SSH key available")
}
signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey))
if err != nil {
return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err)
}
auths := []gossh.AuthMethod{gossh.PublicKeys(signer)}
return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil
case detection.ServerTypeRegular:
var auths []gossh.AuthMethod
if cfg.SSHKey != "" {
if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil {
auths = append(auths, gossh.PublicKeys(signer))
} else {
log.Debugf("ssh: parse netbird key for regular auth: %v", err)
}
}
if password != "" {
pw := password
auths = append(auths, gossh.Password(pw))
auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range questions {
answers[i] = pw
}
return answers, nil
}))
}
if len(auths) == 0 {
// Nothing to offer at all: ask for a password rather than failing,
// so the caller can retry once the user supplies one.
return nil, nil, errPasswordRequired
}
callback, err := s.tofuHostKeyCallback()
if err != nil {
return nil, nil, err
}
return auths, callback, nil
default:
return nil, nil, fmt.Errorf("unsupported SSH server type: %v", serverType)
}
}
// tofuHostKeyCallback verifies a regular server's host key against the
// per-profile known-hosts store. An unknown host returns errHostKeyUnknown so
// Java can show the fingerprint and, once confirmed, retry with the key
// trusted; a changed key is rejected outright, as OpenSSH does. When the user
// has confirmed a fingerprint, the callback accepts exactly that key and
// appends it to the store.
func (s *SSHClient) tofuHostKeyCallback() (gossh.HostKeyCallback, error) {
s.mu.Lock()
configDir := s.knownHostsConfigDir
profileID := s.knownHostsProfile
trusted := s.trustHostKey
s.mu.Unlock()
if configDir == "" || profileID == "" {
return nil, errors.New("no known-hosts store configured for regular SSH")
}
store, err := openKnownHostsStore(configDir, profileID)
if err != nil {
return nil, fmt.Errorf("load known-hosts store: %w", err)
}
return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
verdict, err := store.verify(hostname, remote, key)
if err != nil {
return err
}
if verdict == hostKeyMatched {
return nil
}
if verdict == hostKeyChanged {
return fmt.Errorf("SSH host key changed for %s (possible attack)", hostname)
}
fingerprint := gossh.FingerprintSHA256(key)
if trusted == "" {
return &errHostKeyUnknown{fingerprint: fingerprint}
}
if trusted != fingerprint {
return fmt.Errorf("SSH host key changed since it was confirmed for %s", hostname)
}
if err := store.append(hostname, remote, key); err != nil {
return fmt.Errorf("persist trusted host key: %w", err)
}
// The confirmation is spent: now that the key is stored, a later
// reconnect must verify against the file, not re-accept this fingerprint.
s.mu.Lock()
s.trustHostKey = ""
s.mu.Unlock()
return nil
}, nil
}
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config, cfgPath string) (string, error) {
s.mu.Lock()
urlOpener := s.urlOpener
s.mu.Unlock()
if urlOpener == nil {
return "", errors.New("URL opener not configured for JWT auth")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profileLoginHint(cfgPath))
if err != nil {
return "", fmt.Errorf("create oauth flow: %w", err)
}
// The status callback covers the browser round-trip, which would
// otherwise leave the terminal blank.
tokenInfo, err := runOAuthFlow(ctx, flow, urlOpener, func() {
s.notifyStatus("Waiting for browser authentication...")
})
if err != nil {
return "", err
}
token := tokenInfo.GetTokenToUse()
if token == "" {
return "", errors.New("empty token returned by IdP")
}
// Tells the client the browser round-trip is over so it can dismiss the
// surface it opened, the same way the login and session-extend flows do.
// Without it the Custom Tab stays in front of the terminal even though the
// token has already been collected.
urlOpener.OnLoginSuccess()
return token, nil
}
func (s *SSHClient) dialAndHandshake(gen uint64, host string, port int, clientConfig *gossh.ClientConfig) error {
addr := net.JoinHostPort(host, strconv.Itoa(port))
ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
defer cancel()
s.mu.Lock()
if gen != s.gen {
s.mu.Unlock()
return errClientClosed
}
s.dialCancel = cancel
s.mu.Unlock()
var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}
client, err := nbssh.Handshake(ctx, conn, addr, clientConfig)
if err != nil {
return err
}
s.mu.Lock()
if gen != s.gen {
s.mu.Unlock()
closeQuiet(client, "stale ssh client")
return errClientClosed
}
s.sshClient = client
listener := s.listener
s.mu.Unlock()
if listener != nil {
listener.OnConnected()
}
return nil
}
func (s *SSHClient) readLoop(r io.Reader, name string) string {
buf := make([]byte, 4096)
for {
n, err := r.Read(buf)
if n > 0 {
s.mu.Lock()
listener := s.listener
s.mu.Unlock()
if listener != nil {
chunk := make([]byte, n)
copy(chunk, buf[:n])
listener.OnData(chunk)
}
}
if err != nil {
// EOF is a normal shell exit, so report it without a reason.
if errors.Is(err, io.EOF) {
return ""
}
log.Debugf("ssh %s read: %v", name, err)
return rootCause(err).Error()
}
}
}
// notifyStatus writes a progress line to the terminal through the normal
// output path, so long steps are visible while nothing else is arriving.
func (s *SSHClient) notifyStatus(text string) {
s.mu.Lock()
listener := s.listener
s.mu.Unlock()
if listener != nil {
listener.OnData([]byte("\r\n\x1b[33m" + text + "\x1b[0m\r\n"))
}
}
func (s *SSHClient) notifyClose(gen uint64, reason string) {
s.mu.Lock()
if gen != s.gen || s.closed {
s.mu.Unlock()
return
}
s.closed = true
listener := s.listener
s.mu.Unlock()
if listener != nil {
listener.OnClose(reason)
}
}
func closeQuiet(c io.Closer, label string) {
if c == nil {
return
}
if err := c.Close(); err != nil && !errors.Is(err, io.EOF) {
log.Debugf("ssh: close %s: %v", label, err)
}
}
func detectServerType(host string, port int) detection.ServerType {
ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout)
defer cancel()
dialer := &net.Dialer{}
serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port)
if err != nil {
log.Debugf("ssh: server detection failed: %v (assuming regular SSH)", err)
return detection.ServerTypeRegular
}
return serverType
}
// rootCause returns the innermost error of a %w chain, so the terminal shows
// "i/o timeout" rather than every layer that added context on the way up.
func rootCause(err error) error {
for {
// A joined error has no single root, so keep it as-is.
if _, ok := err.(interface{ Unwrap() []error }); ok {
return err
}
next := errors.Unwrap(err)
if next == nil {
return err
}
err = next
}
}
// isAuthFailure distinguishes credential rejection from dial, timeout and
// host-key errors, which retrying with a password would not fix.
func isAuthFailure(err error) bool {
if errors.Is(err, errPasswordRequired) {
return true
}
var partial *gossh.PartialSuccessError
if errors.As(err, &partial) {
return true
}
return strings.Contains(err.Error(), "unable to authenticate")
}
// passwordCouldHelp reports whether prompting for a password again can change
// the outcome. gossh lists a method under "attempted methods" only when the
// server offered it, so a supplied password that was never attempted means the
// server does not accept passwords and the real error should surface instead.
func passwordCouldHelp(err error, passwordOffered bool) bool {
if !passwordOffered {
return true
}
msg := err.Error()
return strings.Contains(msg, "password") || strings.Contains(msg, "keyboard-interactive")
}

View File

@@ -1,168 +0,0 @@
//go:build android
package android
import (
"bytes"
"net"
"strconv"
"strings"
"sync"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
const knownHostsNamespace = "ssh"
const (
hostKeyUnknown hostKeyVerdict = iota
hostKeyMatched
hostKeyChanged
)
var knownHostsMu sync.Mutex
type hostKeyVerdict uint8
type knownHostsSection struct {
KnownHosts []string `json:"knownHosts"`
}
type knownHostsStore struct {
prefs prefsStore
}
// RemoveKnownHost deletes every known-hosts entry for host:port from the
// profile's store, so a host trusted for a session that is being deleted does
// not linger. Java calls this only once no session targets that host, so a
// shared host stays trusted. A missing entry is not an error: the goal state
// is "absent".
func RemoveKnownHost(configDir, profileID, host string, port int) error {
store, err := openKnownHostsStore(configDir, profileID)
if err != nil {
return err
}
return store.removeHost(host, port)
}
func openKnownHostsStore(configDir, profileID string) (*knownHostsStore, error) {
prefs, err := newProfilePrefs(configDir, profileID)
if err != nil {
return nil, err
}
return &knownHostsStore{prefs: prefs}, nil
}
func (st *knownHostsStore) verify(hostname string, remote net.Addr, key gossh.PublicKey) (hostKeyVerdict, error) {
lines, err := st.lines()
if err != nil {
return hostKeyUnknown, err
}
targets := knownHostsTargets(hostname, remote)
verdict := hostKeyUnknown
for _, line := range lines {
pubKey, ok := knownHostsLineKey(line, targets)
if !ok {
continue
}
if pubKey.Type() == key.Type() && bytes.Equal(pubKey.Marshal(), key.Marshal()) {
return hostKeyMatched, nil
}
verdict = hostKeyChanged
}
return verdict, nil
}
func (st *knownHostsStore) append(hostname string, remote net.Addr, key gossh.PublicKey) error {
line := knownhosts.Line(knownHostsTargets(hostname, remote), key)
knownHostsMu.Lock()
defer knownHostsMu.Unlock()
lines, err := st.lines()
if err != nil {
return err
}
return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: append(lines, line)})
}
func (st *knownHostsStore) removeHost(host string, port int) error {
target := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))
knownHostsMu.Lock()
defer knownHostsMu.Unlock()
lines, err := st.lines()
if err != nil {
return err
}
kept := make([]string, 0, len(lines))
for _, line := range lines {
if knownHostsLineMatches(line, target) {
continue
}
kept = append(kept, line)
}
if len(kept) == len(lines) {
return nil
}
return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: kept})
}
func (st *knownHostsStore) lines() ([]string, error) {
var section knownHostsSection
if _, err := st.prefs.Get(knownHostsNamespace, &section); err != nil {
return nil, err
}
return section.KnownHosts, nil
}
func knownHostsTargets(hostname string, remote net.Addr) []string {
targets := []string{knownhosts.Normalize(hostname)}
if remote != nil {
if normalized := knownhosts.Normalize(remote.String()); normalized != targets[0] {
targets = append(targets, normalized)
}
}
return targets
}
func knownHostsLineKey(line string, targets []string) (gossh.PublicKey, bool) {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
return nil, false
}
_, hosts, pubKey, _, _, err := gossh.ParseKnownHosts([]byte(trimmed))
if err != nil {
return nil, false
}
for _, host := range hosts {
for _, target := range targets {
if host == target {
return pubKey, true
}
}
}
return nil, false
}
// knownHostsLineMatches reports whether a known-hosts line's address list
// contains the normalized target. Comment and blank lines never match.
func knownHostsLineMatches(line, target string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
return false
}
fields := strings.Fields(trimmed)
if len(fields) == 0 {
return false
}
for _, addr := range strings.Split(fields[0], ",") {
if addr == target {
return true
}
}
return false
}

View File

@@ -1,104 +0,0 @@
//go:build android
package android
const (
sshSessionsNamespace = "ssh-sessions"
maxStoredSSHSessions = 50
)
type sshSessionRecord struct {
ID string `json:"id"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
}
type sshSessionsSection struct {
Sessions []sshSessionRecord `json:"sessions"`
}
// SSHSessionEntry is one stored SSH session, without any credential.
type SSHSessionEntry struct {
ID string
Host string
Port int
User string
}
// SSHSessionArray wraps stored SSH sessions for gomobile compatibility.
type SSHSessionArray struct {
items []*SSHSessionEntry
}
// NewSSHSessionArray creates an empty session array to fill via Add.
func NewSSHSessionArray() *SSHSessionArray {
return &SSHSessionArray{}
}
// Add appends a session entry, oldest first.
func (a *SSHSessionArray) Add(id, host string, port int, user string) {
a.items = append(a.items, &SSHSessionEntry{ID: id, Host: host, Port: port, User: user})
}
// Length returns the number of entries.
func (a *SSHSessionArray) Length() int {
return len(a.items)
}
// Get returns the entry at index i, or nil when out of range.
func (a *SSHSessionArray) Get(i int) *SSHSessionEntry {
if i < 0 || i >= len(a.items) {
return nil
}
return a.items[i]
}
// SSHSessionStore reads and writes a profile's stored SSH sessions.
type SSHSessionStore struct {
prefs prefsStore
}
// NewSSHSessionStore opens the session store of the given profile.
func NewSSHSessionStore(configDir, profileID string) (*SSHSessionStore, error) {
prefs, err := newProfilePrefs(configDir, profileID)
if err != nil {
return nil, err
}
return &SSHSessionStore{prefs: prefs}, nil
}
// Load returns the stored sessions, oldest first.
func (s *SSHSessionStore) Load() (*SSHSessionArray, error) {
var section sshSessionsSection
if _, err := s.prefs.Get(sshSessionsNamespace, &section); err != nil {
return nil, err
}
out := NewSSHSessionArray()
for _, record := range section.Sessions {
if record.ID == "" || record.Host == "" {
continue
}
out.Add(record.ID, record.Host, record.Port, record.User)
}
return out, nil
}
// Save replaces the stored sessions, keeping only the newest entries when the
// list exceeds the storage cap.
func (s *SSHSessionStore) Save(sessions *SSHSessionArray) error {
var items []*SSHSessionEntry
if sessions != nil {
items = sessions.items
}
if len(items) > maxStoredSSHSessions {
items = items[len(items)-maxStoredSSHSessions:]
}
records := make([]sshSessionRecord, 0, len(items))
for _, item := range items {
records = append(records, sshSessionRecord{ID: item.ID, Host: item.Host, Port: item.Port, User: item.User})
}
return s.prefs.Put(sshSessionsNamespace, sshSessionsSection{Sessions: records})
}

View File

@@ -408,7 +408,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro
hint = profileState.Email
}
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint)
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint, false)
if err != nil {
return nil, err
}

View File

@@ -21,7 +21,7 @@ import (
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
nbssh "github.com/netbirdio/netbird/client/ssh"
sshcommon "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/domain"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
@@ -521,7 +521,12 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
return err
}
return nbssh.PeerKeyLookup(engine.GetPeerSSHKey).VerifySSHHostKey(peerAddress, key)
storedKey, found := engine.GetPeerSSHKey(peerAddress)
if !found {
return sshcommon.ErrPeerNotFound
}
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
}
// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool

View File

@@ -16,47 +16,28 @@ 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) {
dial := sweeper.StartDial(ctx)
defer dial.Release()
if runtime.GOOS == "linux" {
currentUser, err := user.Current()
if err != nil {
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
}
conn, err := dialContext(dial.Ctx(), addr)
if err != nil {
return nil, 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)
}
}
return dial.WrapConn(conn)
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
}
return conn, nil
})
}
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
}

View File

@@ -3,7 +3,6 @@ package grpc
import (
"google.golang.org/grpc"
"github.com/netbirdio/netbird/client/netsweep"
"github.com/netbirdio/netbird/util/wsproxy/client"
)
@@ -12,8 +11,3 @@ 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{}
}

View File

@@ -1,49 +0,0 @@
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()
}
}
}

View File

@@ -1,91 +0,0 @@
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)
}

View File

@@ -103,7 +103,7 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
// Try PKCE flow first
_, err := a.getPKCEFlow(client)
_, err := a.getPKCEFlow(client, false)
if err == nil {
supportsSSO = true
return nil
@@ -138,37 +138,30 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection
// This avoids creating a new connection to the management server
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint string) (OAuthFlow, error) {
//
// sessionExtend marks the flow as renewing an existing peer's session rather than
// logging one in; the server needs it to rule out a silent authorization that the
// IdP could answer from another account. See PKCEAuthorizationFlowRequest.
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, sessionExtend bool) (OAuthFlow, error) {
var flow OAuthFlow
var err error
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
err = a.withRetry(ctx, func(client *mgm.GrpcClient) error {
if forceDeviceAuth {
deviceFlow, err := a.getDeviceFlow(client)
if err != nil {
return err
}
deviceFlow.SetLoginHint(hint)
flow = deviceFlow
return nil
flow, err = a.getDeviceFlow(client)
return err
}
// Try PKCE flow first
pkceFlow, err := a.getPKCEFlow(client)
flow, err = a.getPKCEFlow(client, sessionExtend)
if err != nil {
// If PKCE not supported, try Device flow
if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) {
deviceFlow, err := a.getDeviceFlow(client)
if err != nil {
return err
}
deviceFlow.SetLoginHint(hint)
flow = deviceFlow
return nil
flow, err = a.getDeviceFlow(client)
return err
}
return err
}
pkceFlow.SetLoginHint(hint)
flow = pkceFlow
return nil
})
@@ -240,8 +233,8 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
}
// getPKCEFlow retrieves PKCE authorization flow configuration and creates a flow instance
func (a *Auth) getPKCEFlow(client *mgm.GrpcClient) (*PKCEAuthorizationFlow, error) {
protoFlow, err := client.GetPKCEAuthorizationFlow()
func (a *Auth) getPKCEFlow(client *mgm.GrpcClient, sessionExtend bool) (*PKCEAuthorizationFlow, error) {
protoFlow, err := client.GetPKCEAuthorizationFlow(sessionExtend)
if err != nil {
if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound {
log.Warnf("server couldn't find pkce flow, contact admin: %v", err)

View File

@@ -70,12 +70,15 @@ func shouldUseDeviceFlow(force bool, isUnixDesktopClient bool) bool {
//
// On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow
// forceDeviceCodeFlow can be used to skip PKCE and go directly to Device Code Flow (e.g., for Android TV)
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string) (OAuthFlow, error) {
//
// sessionExtend marks the flow as renewing an existing peer's session rather than
// logging one in; see PKCEAuthorizationFlowRequest for what the server makes of it.
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string, sessionExtend bool) (OAuthFlow, error) {
if shouldUseDeviceFlow(forceDeviceCodeFlow, isUnixDesktopClient) {
return authenticateWithDeviceCodeFlow(ctx, config, hint)
}
pkceFlow, err := authenticateWithPKCEFlow(ctx, config, hint)
pkceFlow, err := authenticateWithPKCEFlow(ctx, config, hint, sessionExtend)
if err != nil {
log.Debugf("failed to initialize pkce authentication with error: %v\n", err)
log.Debug("falling back to device code flow")
@@ -85,19 +88,21 @@ func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesk
}
// authenticateWithPKCEFlow initializes the Proof Key for Code Exchange flow auth flow
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string) (OAuthFlow, error) {
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string, sessionExtend bool) (OAuthFlow, error) {
authClient, err := NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
if err != nil {
return nil, fmt.Errorf("failed to create auth client: %v", err)
}
defer authClient.Close()
pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client)
pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client, sessionExtend)
if err != nil {
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
}
pkceFlowInfo.SetLoginHint(hint)
if hint != "" {
pkceFlowInfo.SetLoginHint(hint)
}
return pkceFlowInfo, nil
}
@@ -125,7 +130,9 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.
}
}
deviceFlowInfo.SetLoginHint(hint)
if hint != "" {
deviceFlowInfo.SetLoginHint(hint)
}
return deviceFlowInfo, nil
}

View File

@@ -27,7 +27,6 @@ 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"
@@ -39,8 +38,6 @@ 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"
@@ -67,49 +64,24 @@ type ConnectClient struct {
config *profilemanager.Config
statusRecorder *peer.Status
engine *Engine
engineMutex sync.Mutex
clientMetrics *metrics.ClientMetrics
updateManager *updater.Manager
fileDropManager *filedrop.Manager
engine *Engine
engineMutex sync.Mutex
clientMetrics *metrics.ClientMetrics
updateManager *updater.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)
c := &ConnectClient{
return &ConnectClient{
ctx: runCtx,
runCancel: runCancel,
runExited: make(chan struct{}),
@@ -117,22 +89,12 @@ 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 {
@@ -312,13 +274,6 @@ 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)
@@ -330,8 +285,7 @@ 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,
mgm.WithNetworkState(c.netState), mgm.WithSweeper(c.sweeper))
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
if err != nil {
// On daemon shutdown / Down() the parent context is cancelled
// and the dial fails with "context canceled". Wrapping that
@@ -406,7 +360,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, c.netState, c.sweeper)
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey)
if err != nil {
log.Error(err)
return wrapErr(err)
@@ -442,8 +396,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
engineConfig.StateDir = filepath.Dir(path)
}
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU,
relayClient.WithNetworkState(c.netState), relayClient.WithSweeper(c.sweeper))
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
c.statusRecorder.SetRelayMgr(relayManager)
if len(relayURLs) > 0 {
if token != nil {
@@ -471,8 +424,6 @@ 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
@@ -529,16 +480,6 @@ 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)
@@ -732,7 +673,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, netState *netstate.State, sweeper *netsweep.Sweeper) (*signal.GrpcClient, error) {
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key) (*signal.GrpcClient, error) {
var sigTLSEnabled bool
if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS {
sigTLSEnabled = true
@@ -740,8 +681,7 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP
sigTLSEnabled = false
}
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled,
signal.WithNetworkState(netState), signal.WithSweeper(sweeper))
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled)
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)

View File

@@ -40,7 +40,6 @@ 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"
@@ -60,7 +59,6 @@ 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"
@@ -183,10 +181,6 @@ 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.
@@ -210,10 +204,6 @@ 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
@@ -246,10 +236,6 @@ type Engine struct {
sshServer sshServer
fileDrop *filedrop.Manager
fileDropRunning bool
fileDropPort uint16
statusRecorder *peer.Status
firewall firewallManager.Manager
@@ -351,7 +337,6 @@ func NewEngine(
syncMsgMux: &sync.Mutex{},
config: config,
mobileDep: mobileDep,
netState: services.NetState,
STUNs: []*stun.URI{},
TURNs: []*stun.URI{},
networkSerial: 0,
@@ -365,7 +350,6 @@ 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
@@ -431,8 +415,6 @@ func (e *Engine) stopLocked() {
log.Warnf("failed to stop SSH server: %v", err)
}
e.stopFileDrop()
e.cleanupSSHConfig()
if e.ingressGatewayMgr != nil {
@@ -1311,8 +1293,6 @@ 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()
@@ -1913,8 +1893,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
Addr: e.getRosenpassAddr(),
PermissiveMode: e.config.RosenpassPermissive,
},
ICEConfig: e.createICEConfig(),
NetworkState: e.netState,
ICEConfig: e.createICEConfig(),
}
serviceDependencies := peer.ServiceDependencies{
@@ -1981,8 +1960,6 @@ 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 {
@@ -2462,8 +2439,6 @@ 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
@@ -2473,12 +2448,7 @@ func (e *Engine) RenewTun(fd int) error {
return fmt.Errorf("wireguard interface not initialized")
}
if err := wgInterface.RenewTun(fd); err != nil {
return err
}
e.restartFileDrop()
return nil
return wgInterface.RenewTun(fd)
}
// updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag

View File

@@ -1,143 +0,0 @@
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
}

View File

@@ -1,446 +0,0 @@
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)
}
}

View File

@@ -1,100 +0,0 @@
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
}

View File

@@ -1,7 +0,0 @@
//go:build windows || js
package filedrop
func chownToDirOwner(string, string) error {
return nil
}

View File

@@ -1,29 +0,0 @@
//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
}

View File

@@ -1,850 +0,0 @@
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)
}

View File

@@ -1,205 +0,0 @@
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)
}
}

View File

@@ -1,221 +0,0 @@
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})
}

View File

@@ -1,660 +0,0 @@
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)
}

View File

@@ -1,282 +0,0 @@
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
}
}

View File

@@ -1,206 +0,0 @@
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{},
}
}

View File

@@ -1,81 +0,0 @@
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)
}
}

View File

@@ -1,197 +0,0 @@
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))
}
}

View File

@@ -1,226 +0,0 @@
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
}

View File

@@ -1,262 +0,0 @@
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
}

View File

@@ -1,131 +0,0 @@
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)
}
}
}

View File

@@ -1,58 +0,0 @@
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
}

View File

@@ -26,7 +26,6 @@ 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"
)
@@ -94,10 +93,6 @@ 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 {
@@ -259,7 +254,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.config.NetworkState)
conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher)
conn.wg.Add(1)
go func() {

View File

@@ -6,8 +6,6 @@ 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.
@@ -33,26 +31,20 @@ type connStatusFunc func() ConnStatus
// - Relayed connection disconnected
// - ICE candidate changes
type Guard struct {
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
log *log.Entry
isConnectedOnAllWay connStatusFunc
timeout time.Duration
srWatcher *SRWatcher
relayedConnDisconnected chan struct{}
iCEConnDisconnected chan struct{}
}
// 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 {
func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard {
return &Guard{
log: log,
isConnectedOnAllWay: isConnectedFn,
timeout: timeout,
srWatcher: srWatcher,
netState: netState,
relayedConnDisconnected: make(chan struct{}, 1),
iCEConnDisconnected: make(chan struct{}, 1),
}
@@ -104,16 +96,9 @@ 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
@@ -150,23 +135,6 @@ 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

View File

@@ -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, nil)
return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw)
}
// countBackoffTickerGoroutines returns how many goroutines are currently sitting

View File

@@ -1,107 +0,0 @@
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
}

View File

@@ -1,40 +1,11 @@
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)
}

View File

@@ -4,64 +4,31 @@ 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 ClientState
lastNotification int
lastNumberOfPeers int
lastFqdnAddress string
lastIPAddress string
networkAvailable bool
}
func newNotifier() *notifier {
return &notifier{
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)
}
return &notifier{}
}
func (n *notifier) setListener(listener Listener) {
n.serverStateLock.Lock()
lastNotification := n.effectiveState(n.lastNotification)
lastNotification := n.lastNotification
numOfPeers := n.lastNumberOfPeers
fqdnAddress := n.lastFqdnAddress
address := n.lastIPAddress
@@ -85,9 +52,6 @@ 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)
@@ -97,54 +61,43 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) {
}
n.lastNotification = calculatedState
effective := n.effectiveState(calculatedState)
n.serverStateLock.Unlock()
n.notify(effective)
n.notify(calculatedState)
}
func (n *notifier) clientStart() {
n.publishLock.Lock()
defer n.publishLock.Unlock()
n.serverStateLock.Lock()
n.currentClientState = true
n.lastNotification = ClientStateConnecting
effective := n.effectiveState(ClientStateConnecting)
n.lastNotification = stateConnecting
n.serverStateLock.Unlock()
n.notify(effective)
n.notify(stateConnecting)
}
func (n *notifier) clientStop() {
n.publishLock.Lock()
defer n.publishLock.Unlock()
n.serverStateLock.Lock()
n.currentClientState = false
n.lastNotification = ClientStateDisconnected
n.lastNotification = stateDisconnected
n.serverStateLock.Unlock()
n.notify(ClientStateDisconnected)
n.notify(stateDisconnected)
}
func (n *notifier) clientTearDown() {
n.publishLock.Lock()
defer n.publishLock.Unlock()
n.serverStateLock.Lock()
n.currentClientState = false
n.lastNotification = ClientStateDisconnecting
n.lastNotification = stateDisconnecting
n.serverStateLock.Unlock()
n.notify(ClientStateDisconnecting)
n.notify(stateDisconnecting)
}
func (n *notifier) isServerStateChanged(newState ClientState) bool {
func (n *notifier) isServerStateChanged(newState int) bool {
return n.lastNotification != newState
}
func (n *notifier) notify(state ClientState) {
func (n *notifier) notify(state int) {
n.listenersLock.Lock()
listener := n.listener
n.listenersLock.Unlock()
@@ -156,20 +109,20 @@ func (n *notifier) notify(state ClientState) {
notifyListener(listener, state)
}
func (n *notifier) calculateState(managementConn, signalConn bool) ClientState {
func (n *notifier) calculateState(managementConn, signalConn bool) int {
if managementConn && signalConn {
return ClientStateConnected
return stateConnected
}
if !managementConn && !signalConn && !n.currentClientState {
return ClientStateDisconnected
return stateDisconnected
}
if n.lastNotification == ClientStateDisconnecting {
return ClientStateDisconnecting
if n.lastNotification == stateDisconnecting {
return stateDisconnecting
}
return ClientStateConnecting
return stateConnecting
}
func (n *notifier) peerListChanged(numOfPeers int) {
@@ -206,19 +159,15 @@ func (n *notifier) localAddressChanged(fqdn, address string) {
listener.OnAddressChanged(fqdn, address)
}
func notifyListener(l Listener, state ClientState) {
// legacy per-state callbacks; NoNetwork is delivered only via
// OnStateChanged below
func notifyListener(l Listener, state int) {
switch state {
case ClientStateDisconnected:
case stateDisconnected:
l.OnDisconnected()
case ClientStateConnected:
case stateConnected:
l.OnConnected()
case ClientStateConnecting:
case stateConnecting:
l.OnConnecting()
case ClientStateDisconnecting:
case stateDisconnecting:
l.OnDisconnecting()
}
l.OnStateChanged(state)
}

View File

@@ -1,108 +0,0 @@
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())
}
}

View File

@@ -6,32 +6,29 @@ import (
)
type mocListener struct {
lastState ClientState
lastState int
wg sync.WaitGroup
peersWg sync.WaitGroup
peers int
}
func (l *mocListener) OnConnected() {
l.lastState = ClientStateConnected
l.lastState = stateConnected
l.wg.Done()
}
func (l *mocListener) OnDisconnected() {
l.lastState = ClientStateDisconnected
l.lastState = stateDisconnected
l.wg.Done()
}
func (l *mocListener) OnConnecting() {
l.lastState = ClientStateConnecting
l.lastState = stateConnecting
l.wg.Done()
}
func (l *mocListener) OnDisconnecting() {
l.lastState = ClientStateDisconnecting
l.lastState = stateDisconnecting
l.wg.Done()
}
func (l *mocListener) OnStateChanged(state ClientState) {
}
func (l *mocListener) OnAddressChanged(host, addr string) {
}
@@ -60,15 +57,15 @@ func Test_notifier_serverState(t *testing.T) {
type scenario struct {
name string
expected ClientState
expected int
mgmState bool
signalState bool
}
scenarios := []scenario{
{"connected", ClientStateConnected, true, true},
{"mgm down", ClientStateConnecting, false, true},
{"signal down", ClientStateConnecting, true, false},
{"disconnected", ClientStateDisconnected, false, false},
{"connected", stateConnected, true, true},
{"mgm down", stateConnecting, false, true},
{"signal down", stateConnecting, true, false},
{"disconnected", stateDisconnected, false, false},
}
for _, tt := range scenarios {
@@ -88,7 +85,7 @@ func Test_notifier_SetListener(t *testing.T) {
listener.setPeersWaiter()
n := newNotifier()
n.lastNotification = ClientStateConnecting
n.lastNotification = stateConnecting
n.setListener(listener)
listener.wait()
listener.waitPeers()
@@ -102,7 +99,7 @@ func Test_notifier_RemoveListener(t *testing.T) {
listener.setWaiter()
listener.setPeersWaiter()
n := newNotifier()
n.lastNotification = ClientStateConnecting
n.lastNotification = stateConnecting
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.

View File

@@ -1,8 +1,6 @@
package peer
import (
"sync/atomic"
"github.com/pion/ice/v4"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
@@ -14,7 +12,6 @@ import (
type Signaler struct {
signal signal.Client
wgPrivateKey wgtypes.Key
filedropPort atomic.Uint32
}
func NewSignaler(signal signal.Client, wgPrivateKey wgtypes.Key) *Signaler {
@@ -47,12 +44,6 @@ 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
@@ -66,7 +57,6 @@ 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,

View File

@@ -1211,12 +1211,6 @@ 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)

View File

@@ -1,130 +0,0 @@
package profilemanager
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/netbirdio/netbird/util"
)
const prefsFileSuffix = ".prefs.json"
var prefsMu sync.Mutex
// Prefs is a namespaced per-profile preference store backed by a single JSON
// file next to the profile config; it is deleted together with the profile.
type Prefs struct {
path string
}
// ProfilePrefs returns the preference store of the profile identified by id.
func (s *ServiceManager) ProfilePrefs(id ID, username string) (*Prefs, error) {
if !IsValidProfileFilenameStem(id) {
return nil, fmt.Errorf("invalid profile ID: %q", id)
}
if id == defaultProfileName {
return &Prefs{path: filepath.Join(filepath.Dir(DefaultConfigPath), id.String()+prefsFileSuffix)}, nil
}
configDir, err := s.getConfigDir(username)
if err != nil {
return nil, fmt.Errorf("get config directory for user %s: %w", username, err)
}
return &Prefs{path: filepath.Join(configDir, id.String()+prefsFileSuffix)}, nil
}
// Get unmarshals the namespace section into v and reports whether it exists.
func (p *Prefs) Get(namespace string, v any) (bool, error) {
if namespace == "" {
return false, fmt.Errorf("empty prefs namespace")
}
prefsMu.Lock()
defer prefsMu.Unlock()
sections, err := readPrefsFile(p.path)
if err != nil {
return false, err
}
raw, ok := sections[namespace]
if !ok {
return false, nil
}
if err := json.Unmarshal(raw, v); err != nil {
return false, fmt.Errorf("decode prefs namespace %q: %w", namespace, err)
}
return true, nil
}
// Put stores v as the namespace section, replacing any previous value.
func (p *Prefs) Put(namespace string, v any) error {
if namespace == "" {
return fmt.Errorf("empty prefs namespace")
}
raw, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("encode prefs namespace %q: %w", namespace, err)
}
prefsMu.Lock()
defer prefsMu.Unlock()
sections, err := readPrefsFile(p.path)
if err != nil {
return err
}
sections[namespace] = raw
return writePrefsFile(p.path, sections)
}
// Remove deletes the namespace section; a missing one is not an error.
func (p *Prefs) Remove(namespace string) error {
if namespace == "" {
return fmt.Errorf("empty prefs namespace")
}
prefsMu.Lock()
defer prefsMu.Unlock()
sections, err := readPrefsFile(p.path)
if err != nil {
return err
}
if _, ok := sections[namespace]; !ok {
return nil
}
delete(sections, namespace)
return writePrefsFile(p.path, sections)
}
func removePrefsFile(path string) error {
prefsMu.Lock()
defer prefsMu.Unlock()
return os.Remove(path)
}
func readPrefsFile(path string) (map[string]json.RawMessage, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return map[string]json.RawMessage{}, nil
}
if err != nil {
return nil, fmt.Errorf("read prefs: %w", err)
}
sections := map[string]json.RawMessage{}
if err := json.Unmarshal(data, &sections); err != nil {
return nil, fmt.Errorf("decode prefs: %w", err)
}
return sections, nil
}
func writePrefsFile(path string, sections map[string]json.RawMessage) error {
if err := util.WriteJsonWithRestrictedPermission(context.Background(), path, sections); err != nil {
return fmt.Errorf("write prefs: %w", err)
}
return nil
}

View File

@@ -1,138 +0,0 @@
package profilemanager
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testPrefsSection struct {
Mode uint8 `json:"mode"`
Dest string `json:"dest"`
}
func TestProfilePrefs_RoundTrip(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
require.NoError(t, err)
require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 2, Dest: "/tmp/x"}))
require.NoError(t, prefs.Put("other", map[string]int{"n": 1}))
var got testPrefsSection
found, err := prefs.Get("filedrop", &got)
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, testPrefsSection{Mode: 2, Dest: "/tmp/x"}, got)
var other map[string]int
found, err = prefs.Get("other", &other)
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, map[string]int{"n": 1}, other)
})
}
func TestProfilePrefs_GetMissingNamespace(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
require.NoError(t, err)
var got testPrefsSection
found, err := prefs.Get("filedrop", &got)
require.NoError(t, err)
assert.False(t, found)
})
}
func TestProfilePrefs_RemoveNamespace(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
require.NoError(t, err)
require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 1}))
require.NoError(t, prefs.Put("other", map[string]int{"n": 1}))
require.NoError(t, prefs.Remove("filedrop"))
require.NoError(t, prefs.Remove("missing"))
var got testPrefsSection
found, err := prefs.Get("filedrop", &got)
require.NoError(t, err)
assert.False(t, found)
var other map[string]int
found, err = prefs.Get("other", &other)
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, map[string]int{"n": 1}, other)
})
}
func TestProfilePrefs_RejectsInvalidID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
_, err := sm.ProfilePrefs("../escape", username)
assert.Error(t, err)
})
}
func TestProfilePrefs_RejectsEmptyNamespace(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
require.NoError(t, err)
_, err = prefs.Get("", &testPrefsSection{})
assert.Error(t, err)
assert.Error(t, prefs.Put("", testPrefsSection{}))
assert.Error(t, prefs.Remove(""))
})
}
func TestProfilePrefs_DefaultProfile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
prefs, err := sm.ProfilePrefs(defaultProfileName, username)
require.NoError(t, err)
require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 1}))
expected := filepath.Join(filepath.Dir(DefaultConfigPath), "default"+prefsFileSuffix)
_, err = os.Stat(expected)
require.NoError(t, err)
})
}
func TestRemoveProfile_DeletesPrefsFile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
require.NoError(t, err)
require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 2}))
configDir, err := sm.getConfigDir(username)
require.NoError(t, err)
prefsPath := filepath.Join(configDir, created.ID.String()+prefsFileSuffix)
_, err = os.Stat(prefsPath)
require.NoError(t, err)
require.NoError(t, sm.RemoveProfile(created.ID, username))
_, err = os.Stat(prefsPath)
assert.True(t, errors.Is(err, os.ErrNotExist), "prefs file should be removed")
})
}

View File

@@ -420,11 +420,6 @@ func (s *ServiceManager) RemoveProfile(id ID, username string) error {
log.Warnf("failed to remove profile state file %s: %v", stateFile, err)
}
prefsFile := filepath.Join(filepath.Dir(target.Path), id.String()+prefsFileSuffix)
if err := removePrefsFile(prefsFile); err != nil && !os.IsNotExist(err) {
log.Warnf("failed to remove profile prefs file %s: %v", prefsFile, err)
}
return nil
}

View File

@@ -22,8 +22,6 @@ 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"
@@ -38,6 +36,11 @@ 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
@@ -84,12 +87,6 @@ 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
@@ -112,8 +109,6 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
ctxCancelLock: &sync.Mutex{},
networkChangeListener: networkChangeListener,
dnsManager: dnsManager,
netState: netstate.New(),
sweeper: netsweep.New(),
}
}
@@ -189,8 +184,7 @@ 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,
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
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
@@ -199,25 +193,6 @@ 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()
@@ -356,11 +331,7 @@ func (c *Client) GetStatusDetails() *StatusDetails {
// SetConnectionListener set the network connection listener
func (c *Client) SetConnectionListener(listener ConnectionListener) {
if listener == nil {
c.recorder.RemoveConnectionListener()
return
}
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
c.recorder.SetConnectionListener(listener)
}
// RemoveConnectionListener remove connection listener
@@ -458,7 +429,7 @@ func (c *Client) LoginForMobile() string {
return fmt.Sprintf("failed to load config: %v", err)
}
oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, false, false, "")
oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, false, false, "", false)
if err != nil {
return err.Error()
}

View File

@@ -1,43 +0,0 @@
//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) {}

View File

@@ -323,7 +323,7 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
const authInfoRequestTimeout = 30 * time.Second
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, forceDeviceAuth bool) (*auth.TokenInfo, error) {
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, "")
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, false)
if err != nil {
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
}

View File

@@ -1,107 +0,0 @@
// 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:
}
}
}

View File

@@ -1,170 +0,0 @@
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()
}

View File

@@ -1,267 +0,0 @@
// 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()
}

View File

@@ -1,241 +0,0 @@
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)
}

View File

@@ -1,39 +0,0 @@
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()
}

View File

@@ -1,58 +0,0 @@
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

View File

@@ -1123,198 +1123,6 @@ 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.
@@ -2189,166 +1997,6 @@ 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
}
@@ -3171,142 +2819,6 @@ 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
}
@@ -3357,14 +2869,6 @@ 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 (
@@ -3414,12 +2918,4 @@ 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
)

View File

@@ -156,32 +156,6 @@ 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) {}
}
@@ -1089,119 +1063,3 @@ 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 {}

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v7.34.1
// - protoc v6.33.1
// source: daemon.proto
package proto
@@ -65,14 +65,6 @@ 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.
@@ -179,24 +171,6 @@ 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 {
@@ -703,86 +677,6 @@ 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.
@@ -887,24 +781,6 @@ 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()
}
@@ -1053,30 +929,6 @@ 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() {}
@@ -1898,150 +1750,6 @@ 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)
@@ -2217,38 +1925,6 @@ 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{
{

View File

@@ -59,24 +59,3 @@ 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"
)

View File

@@ -1,412 +0,0 @@
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)
}
}

View File

@@ -1,25 +0,0 @@
//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
}

View File

@@ -1,11 +0,0 @@
package server
import (
"os"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
func checkDirOwnership(ipcauth.Identity, string, os.FileInfo) error {
return nil
}

View File

@@ -31,7 +31,6 @@ 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"
@@ -62,6 +61,10 @@ const (
var ErrServiceNotUp = errors.New("service is not up")
type statusSetter interface {
Set(update internal.StatusType)
}
// Server for service control.
type Server struct {
rootCtx context.Context
@@ -110,8 +113,6 @@ type Server struct {
statusRecorder *peer.Status
sessionWatcher *internal.SessionWatcher
fileDrop *filedrop.Manager
probeThrottle *probeThrottle
persistSyncResponse bool
isSessionActive atomic.Bool
@@ -678,54 +679,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}
if msg.SetupKey == "" {
hint := ""
if msg.Hint != nil {
hint = *msg.Hint
}
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint)
if err != nil {
state.Set(internal.StatusLoginFailed)
return nil, err
}
if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) {
if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
log.Debugf("using previous oauth flow info")
state.Set(internal.StatusNeedsLogin)
return &proto.LoginResponse{
NeedsSSOLogin: true,
VerificationURI: s.oauthAuthFlow.info.VerificationURI,
VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete,
UserCode: s.oauthAuthFlow.info.UserCode,
}, nil
} else {
log.Warnf("canceling previous waiting execution")
if s.oauthAuthFlow.waitCancel != nil {
s.oauthAuthFlow.waitCancel()
}
}
}
authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
if err != nil {
log.Errorf("getting a request OAuth flow failed: %v", err)
return nil, err
}
s.mutex.Lock()
s.oauthAuthFlow.flow = oAuthFlow
s.oauthAuthFlow.info = authInfo
s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
s.mutex.Unlock()
state.Set(internal.StatusNeedsLogin)
return &proto.LoginResponse{
NeedsSSOLogin: true,
VerificationURI: authInfo.VerificationURI,
VerificationURIComplete: authInfo.VerificationURIComplete,
UserCode: authInfo.UserCode,
}, nil
return s.startSSOLogin(ctx, msg, config, state)
}
// Setup-key path: we are about to dial Management with the key, so the
@@ -741,6 +695,72 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
return &proto.LoginResponse{}, nil
}
// startSSOLogin opens the interactive leg of a login: it reuses the in-flight
// OAuth flow when one is still valid for the same client, and otherwise
// requests fresh auth info and parks the daemon on StatusNeedsLogin.
func (s *Server) startSSOLogin(ctx context.Context, msg *proto.LoginRequest, config *profilemanager.Config, state statusSetter) (*proto.LoginResponse, error) {
hint := ""
if msg.Hint != nil {
hint = *msg.Hint
}
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint, false)
if err != nil {
state.Set(internal.StatusLoginFailed)
return nil, err
}
if resp := s.reuseOAuthFlow(ctx, oAuthFlow, state); resp != nil {
return resp, nil
}
authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
if err != nil {
log.Errorf("getting a request OAuth flow failed: %v", err)
return nil, err
}
s.mutex.Lock()
s.oauthAuthFlow.flow = oAuthFlow
s.oauthAuthFlow.info = authInfo
s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
s.mutex.Unlock()
state.Set(internal.StatusNeedsLogin)
return &proto.LoginResponse{
NeedsSSOLogin: true,
VerificationURI: authInfo.VerificationURI,
VerificationURIComplete: authInfo.VerificationURIComplete,
UserCode: authInfo.UserCode,
}, nil
}
// reuseOAuthFlow returns the cached auth info when the previous flow targets
// the same client and still has enough life left, and otherwise cancels the
// stale wait and returns nil so the caller requests a fresh flow.
func (s *Server) reuseOAuthFlow(ctx context.Context, oAuthFlow auth.OAuthFlow, state statusSetter) *proto.LoginResponse {
if s.oauthAuthFlow.flow == nil || s.oauthAuthFlow.flow.GetClientID(ctx) != oAuthFlow.GetClientID(ctx) {
return nil
}
if !s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
log.Warnf("canceling previous waiting execution")
if s.oauthAuthFlow.waitCancel != nil {
s.oauthAuthFlow.waitCancel()
}
return nil
}
log.Debugf("using previous oauth flow info")
state.Set(internal.StatusNeedsLogin)
return &proto.LoginResponse{
NeedsSSOLogin: true,
VerificationURI: s.oauthAuthFlow.info.VerificationURI,
VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete,
UserCode: s.oauthAuthFlow.info.UserCode,
}
}
// WaitSSOLogin validates the supplied userCode against the in-flight OAuth
// device/PKCE flow and blocks until the user finishes the browser leg.
//
@@ -1727,7 +1747,7 @@ func (s *Server) RequestJWTAuth(
}
// the daemon has no graphical session of its own, only the caller can answer this
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint)
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint, false)
if err != nil {
return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err)
}
@@ -1831,7 +1851,7 @@ func (s *Server) RequestExtendAuthSession(
}
// the daemon has no graphical session of its own, only the caller can answer this
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint)
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint, true)
if err != nil {
return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err)
}
@@ -2354,12 +2374,6 @@ 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()

View File

@@ -313,23 +313,21 @@ func Dial(ctx context.Context, addr, user string, opts DialOptions) (*Client, er
// dialSSH establishes an SSH connection without JWT authentication
func dialSSH(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*Client, error) {
if config.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, config.Timeout)
defer cancel()
}
dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, network, addr)
if err != nil {
return nil, fmt.Errorf("dial %s: %w", addr, err)
}
client, err := nbssh.Handshake(ctx, conn, addr, config)
clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
if err != nil {
return nil, err
if closeErr := conn.Close(); closeErr != nil {
log.Debugf("connection close after handshake failure: %v", closeErr)
}
return nil, fmt.Errorf("ssh handshake: %w", err)
}
client := ssh.NewClient(clientConn, chans, reqs)
return &Client{
client: client,
}, nil

View File

@@ -12,8 +12,6 @@ import (
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
nbssh "github.com/netbirdio/netbird/client/ssh"
)
func (c *Client) setupTerminalMode(ctx context.Context, session *ssh.Session) error {
@@ -84,7 +82,37 @@ func (c *Client) setupTerminal(session *ssh.Session, fd int) error {
return fmt.Errorf("get terminal size: %w", err)
}
modes := nbssh.DefaultTerminalModes
modes := ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
// Ctrl+C
ssh.VINTR: 3,
// Ctrl+\
ssh.VQUIT: 28,
// Backspace
ssh.VERASE: 127,
// Ctrl+U
ssh.VKILL: 21,
// Ctrl+D
ssh.VEOF: 4,
ssh.VEOL: 0,
ssh.VEOL2: 0,
// Ctrl+Q
ssh.VSTART: 17,
// Ctrl+S
ssh.VSTOP: 19,
// Ctrl+Z
ssh.VSUSP: 26,
// Ctrl+O
ssh.VDISCARD: 15,
// Ctrl+R
ssh.VREPRINT: 18,
// Ctrl+W
ssh.VWERASE: 23,
// Ctrl+V
ssh.VLNEXT: 22,
}
terminal := os.Getenv("TERM")
if terminal == "" {

View File

@@ -10,8 +10,6 @@ import (
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
nbssh "github.com/netbirdio/netbird/client/ssh"
)
const (
@@ -82,14 +80,28 @@ func (c *Client) setupTerminalMode(_ context.Context, session *ssh.Session) erro
w, h := c.getWindowsConsoleSize()
modes := ssh.TerminalModes{
ssh.ICRNL: 1,
ssh.OPOST: 1,
ssh.ONLCR: 1,
ssh.ISIG: 1,
ssh.ICANON: 1,
}
for mode, value := range nbssh.DefaultTerminalModes {
modes[mode] = value
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
ssh.ICRNL: 1,
ssh.OPOST: 1,
ssh.ONLCR: 1,
ssh.ISIG: 1,
ssh.ICANON: 1,
ssh.VINTR: 3, // Ctrl+C
ssh.VQUIT: 28, // Ctrl+\
ssh.VERASE: 127, // Backspace
ssh.VKILL: 21, // Ctrl+U
ssh.VEOF: 4, // Ctrl+D
ssh.VEOL: 0,
ssh.VEOL2: 0,
ssh.VSTART: 17, // Ctrl+Q
ssh.VSTOP: 19, // Ctrl+S
ssh.VSUSP: 26, // Ctrl+Z
ssh.VDISCARD: 15, // Ctrl+O
ssh.VWERASE: 23, // Ctrl+W
ssh.VLNEXT: 22, // Ctrl+V
ssh.VREPRINT: 18, // Ctrl+R
}
if err := session.RequestPty("xterm-256color", h, w, modes); err != nil {

View File

@@ -35,19 +35,6 @@ type HostKeyVerifier interface {
VerifySSHHostKey(peerAddress string, key []byte) error
}
// PeerKeyLookup returns the stored SSH host key for a peer address.
type PeerKeyLookup func(peerAddress string) ([]byte, bool)
// VerifySSHHostKey implements HostKeyVerifier by looking up the stored key
// and comparing it against the presented key.
func (l PeerKeyLookup) VerifySSHHostKey(peerAddress string, presentedKey []byte) error {
storedKey, found := l(peerAddress)
if !found {
return ErrPeerNotFound
}
return VerifyHostKey(storedKey, presentedKey, peerAddress)
}
// DaemonHostKeyVerifier implements HostKeyVerifier using the NetBird daemon
type DaemonHostKeyVerifier struct {
client proto.DaemonServiceClient

View File

@@ -1,45 +0,0 @@
package ssh
import (
"context"
"fmt"
"io"
"net"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
)
// Handshake runs the SSH client handshake on an already dialed conn and
// returns the resulting client. Dialing bounds only the TCP establishment;
// without a deadline on the socket a peer that accepts and then goes silent
// blocks the handshake forever, so the context deadline is applied to conn
// for the duration of the handshake. conn is closed on any error.
func Handshake(ctx context.Context, conn net.Conn, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
if deadline, ok := ctx.Deadline(); ok {
if err := conn.SetDeadline(deadline); err != nil {
closeHandshake(conn, "conn after deadline error")
return nil, fmt.Errorf("set handshake deadline: %w", err)
}
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
if err != nil {
closeHandshake(conn, "conn after handshake error")
return nil, fmt.Errorf("ssh handshake: %w", err)
}
if err := conn.SetDeadline(time.Time{}); err != nil {
closeHandshake(sshConn, "ssh conn after deadline clear error")
return nil, fmt.Errorf("clear handshake deadline: %w", err)
}
return ssh.NewClient(sshConn, chans, reqs), nil
}
func closeHandshake(c io.Closer, label string) {
if err := c.Close(); err != nil {
log.Debugf("ssh: close %s: %v", label, err)
}
}

View File

@@ -610,10 +610,13 @@ func (p *SSHProxy) dialBackend(ctx context.Context, addr, user, jwtToken string)
return nil, fmt.Errorf("connect to server: %w", err)
}
handshakeCtx, cancel := context.WithTimeout(ctx, sshHandshakeTimeout)
defer cancel()
clientConn, chans, reqs, err := cryptossh.NewClientConn(conn, addr, config)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("SSH handshake: %w", err)
}
return nbssh.Handshake(handshakeCtx, conn, addr, config)
return cryptossh.NewClient(clientConn, chans, reqs), nil
}
func (p *SSHProxy) verifyHostKey(hostname string, remote net.Addr, key cryptossh.PublicKey) error {

View File

@@ -1,84 +0,0 @@
package ssh
import (
"fmt"
"io"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
)
// DefaultTerminalModes are the PTY modes used by the interactive terminal clients.
var DefaultTerminalModes = ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
ssh.VINTR: 3, // Ctrl+C
ssh.VQUIT: 28, // Ctrl+\
ssh.VERASE: 127, // Backspace
ssh.VKILL: 21, // Ctrl+U
ssh.VEOF: 4, // Ctrl+D
ssh.VEOL: 0,
ssh.VEOL2: 0,
ssh.VSTART: 17, // Ctrl+Q
ssh.VSTOP: 19, // Ctrl+S
ssh.VSUSP: 26, // Ctrl+Z
ssh.VDISCARD: 15, // Ctrl+O
ssh.VREPRINT: 18, // Ctrl+R
ssh.VWERASE: 23, // Ctrl+W
ssh.VLNEXT: 22, // Ctrl+V
}
// PTYSession is an interactive shell session with a PTY and its I/O pipes.
type PTYSession struct {
Session *ssh.Session
Stdin io.WriteCloser
Stdout io.Reader
Stderr io.Reader
}
// StartPTYSession opens a session on the client, requests an xterm-256color PTY
// with the default terminal modes, wires up the I/O pipes and starts a shell.
// The session is closed on any error.
func StartPTYSession(client *ssh.Client, cols, rows int) (*PTYSession, error) {
session, err := client.NewSession()
if err != nil {
return nil, fmt.Errorf("new session: %w", err)
}
pty, err := setupPTYSession(session, cols, rows)
if err != nil {
if closeErr := session.Close(); closeErr != nil {
log.Debugf("ssh: session close after setup error: %v", closeErr)
}
return nil, err
}
return pty, nil
}
// setupPTYSession requests the PTY, opens the pipes and starts the shell on an
// already created session.
func setupPTYSession(session *ssh.Session, cols, rows int) (*PTYSession, error) {
if err := session.RequestPty("xterm-256color", rows, cols, DefaultTerminalModes); err != nil {
return nil, fmt.Errorf("request pty: %w", err)
}
stdin, err := session.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := session.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := session.StderrPipe()
if err != nil {
return nil, fmt.Errorf("stderr pipe: %w", err)
}
if err := session.Shell(); err != nil {
return nil, fmt.Errorf("start shell: %w", err)
}
return &PTYSession{Session: session, Stdin: stdin, Stdout: stdout, Stderr: stderr}, nil
}

View File

@@ -1,6 +1,6 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
export type NavSection = "peers" | "networks" | "files";
export type NavSection = "peers" | "networks";
type NavSectionContextValue = {
section: NavSection;

View File

@@ -1,47 +0,0 @@
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";
}
};

View File

@@ -11,7 +11,6 @@ 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";
@@ -45,10 +44,7 @@ const MainBody = () => {
const isAdvanced = viewMode === "advanced";
return (
// min-w-0 matters here: without it this flex parent keeps its automatic
// minimum width, and a long file name in the right panel pushes the
// layout wider than the window, which cannot be resized.
<main className={"wails-draggable flex min-h-0 min-w-0 flex-1"}>
<main className={"wails-draggable flex min-h-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
@@ -102,14 +98,10 @@ const AdvancedAppRightPanel = () => {
role={"tabpanel"}
id={`nb-tabpanel-${section}`}
aria-labelledby={`nb-tab-${section}`}
// 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"}
className={"flex min-h-0 flex-1 flex-col"}
>
{section === "peers" && <Peers />}
{section === "networks" && <Networks />}
{section === "files" && <Files />}
</div>
</div>
{!isConnected && (

View File

@@ -1,6 +1,6 @@
import { type ComponentType, type KeyboardEvent, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { FolderDownIcon, Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react";
import { 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,11 +40,6 @@ export const Navigation = () => {
icon: Layers3Icon,
});
}
tabs.push({
value: "files",
label: t("nav.files.title"),
icon: FolderDownIcon,
});
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
@@ -108,7 +103,7 @@ export const Navigation = () => {
onKeyDown={handleKeyDown}
disabled={isDisabled}
className={cn(
"group relative flex min-w-0 flex-1 items-center justify-center",
"group relative flex flex-1 items-center justify-center",
"gap-2.5 px-5 py-3.5",
"outline-none transition-all",
isFirst && "rounded-tl-xl",
@@ -118,8 +113,8 @@ export const Navigation = () => {
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
)}
>
<Icon size={14} className={"shrink-0"} aria-hidden={"true"} />
<span className={"truncate text-sm font-normal"}>{tab.label}</span>
<Icon size={14} aria-hidden={"true"} />
<span className={"text-sm font-normal"}>{tab.label}</span>
<span
aria-hidden={"true"}
className={cn(

View File

@@ -1,494 +0,0 @@
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>
);
};

View File

@@ -20,7 +20,6 @@ import {
Check as CheckIcon,
ChevronDownIcon,
ChevronsLeftRightEllipsisIcon,
ClipboardIcon,
ClockIcon,
Copy as CopyIcon,
GaugeIcon,
@@ -32,13 +31,9 @@ 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";
@@ -255,7 +250,6 @@ 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
@@ -278,76 +272,6 @@ 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 => {

View File

@@ -1,21 +1,9 @@
import {
type DragEvent,
type KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { type KeyboardEvent, 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, SendIcon } from "lucide-react";
import { FileDrop } from "@bindings/services";
import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react";
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";
@@ -53,36 +41,13 @@ 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();
@@ -170,26 +135,9 @@ export const Peers = () => {
{filtered.length === 0 ? (
<NoResults />
) : (
<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.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && (
<PeersList
data={filtered}
scrollParent={scrollParent}
dropTarget={dropTarget}
onDropTarget={setDropTargetBoth}
/>
)}
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
@@ -215,11 +163,9 @@ const ListTopSpacer = () => <div className={"h-2"} />;
type PeersListProps = {
data: PeerStatus[];
scrollParent: HTMLElement;
dropTarget: string | null;
onDropTarget: (pubKey: string | null) => void;
};
const PeersList = ({ data, scrollParent, dropTarget, onDropTarget }: PeersListProps) => {
const PeersList = ({ data, scrollParent }: PeersListProps) => {
const { setSelected } = usePeerDetail();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
@@ -275,15 +221,9 @@ const PeersList = ({ data, scrollParent, dropTarget, onDropTarget }: PeersListPr
};
const ctx = useMemo<PeerRowContext>(
() => ({
onKeyDown: handleRowKeyDown,
onSelect: setSelected,
setRowRef,
dropTarget,
onDropTarget,
}),
() => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[data, setSelected, dropTarget, onDropTarget],
[data, setSelected],
);
return (
@@ -304,8 +244,6 @@ 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 => (
@@ -315,8 +253,6 @@ 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}
/>
);
@@ -326,19 +262,9 @@ 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,
isDropTarget,
onDropTarget,
}: PeerRowProps) => {
const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) => {
const { t } = useTranslation();
const isConnected = peer.connStatus === "Connected";
const peerName = shortenDns(peer.fqdn) || peer.ip;
@@ -346,29 +272,12 @@ const PeerRow = ({
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}

View File

@@ -1,222 +0,0 @@
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>
);
}

View File

@@ -6,7 +6,6 @@ import { useClientVersion } from "@/contexts/ClientVersionContext.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
import {
BoltIcon,
FolderDownIcon,
InfoIcon,
LifeBuoyIcon,
NetworkIcon,
@@ -50,11 +49,6 @@ export const SettingsNavigation = () => {
/>
</>
)}
<VerticalTabs.Trigger
value={"fileSharing"}
icon={FolderDownIcon}
title={t("settings.tabs.fileSharing")}
/>
{!features.disableProfiles && (
<VerticalTabs.Trigger
value={"profiles"}

View File

@@ -12,7 +12,6 @@ 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";
@@ -25,7 +24,6 @@ const enum Tab {
General = "general",
Network = "network",
Security = "security",
FileSharing = "fileSharing",
Profiles = "profiles",
SSH = "ssh",
Advanced = "advanced",
@@ -37,7 +35,6 @@ 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 />,
@@ -56,7 +53,6 @@ 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,

View File

@@ -1338,174 +1338,5 @@
},
"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"
}
}

View File

@@ -1810,229 +1810,5 @@
"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."
}
}

View File

@@ -1338,174 +1338,5 @@
},
"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"
}
}

View File

@@ -1338,174 +1338,5 @@
},
"error.unknown": {
"message": "Lopé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"
}
}

View File

@@ -1338,174 +1338,5 @@
},
"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"
}
}

View File

@@ -1338,174 +1338,5 @@
},
"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"
}
}

View File

@@ -1338,174 +1338,5 @@
},
"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": "常に承認"
}
}

View File

@@ -1338,174 +1338,5 @@
},
"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"
}
}

View File

@@ -1338,174 +1338,5 @@
},
"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": "Всегда принимать"
}
}

View File

@@ -1338,174 +1338,5 @@
},
"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": "始终接受"
}
}

View File

@@ -56,7 +56,6 @@ 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
@@ -124,11 +123,9 @@ 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,
@@ -173,7 +170,6 @@ func main() {
tray = NewTray(app, window, TrayServices{
Connection: connection,
FileDrop: fileDrop,
Settings: settings,
Profiles: profiles,
Networks: networks,
@@ -330,7 +326,6 @@ 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))
@@ -364,7 +359,6 @@ 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(),
@@ -374,10 +368,6 @@ 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() {

Some files were not shown because too many files have changed in this diff Show More