Compare commits

..

2 Commits

Author SHA1 Message Date
Zoltan Papp
66e0d10c60 [client] Align stale-result warning with log message style 2026-08-14 13:38:02 +02:00
Zoltan Papp
ef5af16bbf [client] Clear stale installer result before starting update
The installer result file could survive a previous update attempt (e.g.
when the updater wrote it after the restarted daemon already ran its
startup check). A new install attempt left the old file in place, so the
GUI progress window's first GetInstallerResult poll read the outdated
result: a stale success made the GUI quit mid-install, which cancelled
the TriggerUpdate context and aborted the artifact verification; a stale
error surfaced a bogus failure dialog for a succeeding update.

Remove any leftover result file at the start of RunInstallation, before
the download begins, so result watchers only see the current attempt's
outcome.
2026-08-14 12:42:53 +02:00
125 changed files with 649 additions and 14832 deletions

View File

@@ -1,42 +0,0 @@
name: UI Translations
on:
pull_request:
paths:
- "client/ui/i18n/locales/**"
- "client/ui/i18n/check-translations.mjs"
- ".github/workflows/ui-translations.yml"
push:
branches:
- main
paths:
- "client/ui/i18n/locales/**"
- "client/ui/i18n/check-translations.mjs"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
cancel-in-progress: true
jobs:
check-translations:
name: Check translation key parity
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
# English (en) is the source of truth for translation keys; every other
# locale declared in _index.json must carry the exact same key set.
- name: Check translation key parity
run: node client/ui/i18n/check-translations.mjs

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,39 @@ 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))
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
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 or logged-out profile
// leaves the choice to the IdP, which is how accounts get switched.
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

@@ -22,8 +22,7 @@ type Profile struct {
ID string
Name string
// Email is the account this profile last logged in with, "" if it never
// completed an SSO login. Kept across logouts; cleared when the profile is
// removed. See profile_state.go.
// completed an SSO login or was logged out. See profile_state.go.
Email string
IsActive bool
}
@@ -201,9 +200,11 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
return fmt.Errorf("failed to save config: %w", err)
}
// The stored account email is kept on purpose, matching the desktop and CLI
// logout semantics: the next login passes it as the login_hint so the IdP
// preselects the account. Removing the profile is what deletes it.
// Not fatal: a stale hint costs an account switch, not the logout itself.
if err := removeProfileEmail(configPath); err != nil {
log.Warnf("failed to clear stored account email for profile %s: %v", id, err)
}
log.Infof("logged out from profile: %s", id)
return nil
}
@@ -223,24 +224,11 @@ func (pm *ProfileManager) RenameProfile(id string, newName string) error {
// RemoveProfile deletes a profile
func (pm *ProfileManager) RemoveProfile(id string) error {
configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return err
}
// Use ServiceManager (removes profile from profiles/ directory)
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil {
return fmt.Errorf("failed to remove profile: %w", err)
}
// The account file is this package's, not the ServiceManager's, so it must
// go here. The default profile has a fixed filename, so a recreated one
// would otherwise inherit the deleted profile's email as its login_hint.
// Not fatal: the profile itself is gone.
if err := removeProfileEmail(configPath); err != nil {
log.Warnf("failed to remove stored account email for profile %s: %v", id, err)
}
log.Infof("removed profile: %s", id)
return 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.
@@ -117,10 +90,10 @@ func writeProfileEmail(configPath string, email string) error {
return nil
}
// removeProfileEmail drops the stored account email. Called on profile removal,
// not on logout: a logged-out profile keeps its email so the next login passes
// it as the login_hint, matching the desktop and CLI semantics. Mirrors the
// desktop UI's RemoveProfileState call.
// removeProfileEmail drops the stored account email. Called on logout: while the
// email is on disk it goes out as a login_hint, which would steer the next login
// straight back into the account just logged out of. Mirrors the desktop UI's
// RemoveProfileState call.
func removeProfileEmail(configPath string) error {
accountPath, err := profileAccountPathFor(configPath)
if err != nil {

View File

@@ -127,10 +127,10 @@ func TestWriteThenReadProfileEmail(t *testing.T) {
t.Fatalf("remove: %v", err)
}
if got := readProfileEmail(configPath); got != "" {
t.Errorf("expected no email after removal, got %q", got)
t.Errorf("expected no email after logout, got %q", got)
}
// Removal may run on a never-logged-in profile, so a second remove must pass.
// Logout may run on a never-logged-in profile, so a second remove must pass.
if err := removeProfileEmail(configPath); err != nil {
t.Fatalf("second remove should be a no-op: %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

@@ -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

@@ -138,37 +138,26 @@ 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) {
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth 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)
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
})

View File

@@ -97,7 +97,9 @@ func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config
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 +127,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

@@ -42,6 +42,9 @@ func NewWithDir(tempDir string) *Installer {
// This will run by the original service process
func (u *Installer) RunInstallation(ctx context.Context, targetVersion string) (err error) {
resultHandler := NewResultHandler(u.tempDir)
if err := resultHandler.ClearStaleResult(); err != nil {
log.Warnf("clear stale installer result: %v", err)
}
defer func() {
if err != nil {

View File

@@ -54,6 +54,12 @@ func (rh *ResultHandler) GetErrorResultReason() string {
return ""
}
// ClearStaleResult removes a result file left over from a previous installation
// attempt so result watchers cannot read an outdated outcome for the current attempt.
func (rh *ResultHandler) ClearStaleResult() error {
return rh.cleanup()
}
func (rh *ResultHandler) WriteSuccess() error {
result := Result{
Success: true,

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

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)
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"
@@ -110,8 +109,6 @@ type Server struct {
statusRecorder *peer.Status
sessionWatcher *internal.SessionWatcher
fileDrop *filedrop.Manager
probeThrottle *probeThrottle
persistSyncResponse bool
isSessionActive atomic.Bool
@@ -2354,12 +2351,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

@@ -15,8 +15,7 @@
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
"check": "pnpm lint && pnpm typecheck && pnpm format:check",
"check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck",
"i18n:check": "node ../i18n/check-translations.mjs"
"check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",

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

@@ -1,104 +0,0 @@
#!/usr/bin/env node
// Validates that every shipped translation bundle carries exactly the same set
// of keys as the English source of truth. English (en) defines the keys; every
// other locale declared in _index.json must match it 1:1:
//
// - no missing keys — a missing key silently falls back to English at runtime
// (see i18n bundle fallback), so the gap never surfaces to users or CI
// without this check;
// - no orphaned keys — keys left behind after an English key is renamed or
// removed are dead weight and a sign the locale is drifting.
//
// Pure Node, no dependencies, so it runs without installing the frontend
// toolchain.
//
// Local: node client/ui/i18n/check-translations.mjs (or: pnpm i18n:check)
// CI: .github/workflows/ui-translations.yml
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const SOURCE = "en";
const localesDir = join(dirname(fileURLToPath(import.meta.url)), "locales");
const isCI = Boolean(process.env.GITHUB_ACTIONS);
function readJSON(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function keysOf(langCode) {
return Object.keys(readJSON(join(localesDir, langCode, "common.json")));
}
// Emit a GitHub Actions annotation so failures render inline on the PR diff.
function annotate(file, message) {
if (isCI) console.log(`::error file=${file}::${message}`);
}
const index = readJSON(join(localesDir, "_index.json"));
const declared = index.languages.map((l) => l.code);
if (!declared.includes(SOURCE)) {
console.error(`FATAL: source language "${SOURCE}" is not declared in _index.json`);
process.exit(1);
}
const sourceKeys = keysOf(SOURCE);
const sourceSet = new Set(sourceKeys);
console.log(`Source of truth: ${SOURCE}/common.json — ${sourceKeys.length} keys\n`);
let failed = false;
for (const code of declared) {
if (code === SOURCE) continue;
const file = `client/ui/i18n/locales/${code}/common.json`;
let keys;
try {
keys = keysOf(code);
} catch (e) {
failed = true;
const msg = `bundle is declared in _index.json but common.json is missing or invalid (${e.message})`;
console.error(`${code}: ${msg}`);
annotate("client/ui/i18n/locales/_index.json", `${code}: ${msg}`);
continue;
}
const set = new Set(keys);
const missing = sourceKeys.filter((k) => !set.has(k));
const extra = keys.filter((k) => !sourceSet.has(k));
if (missing.length === 0 && extra.length === 0) {
console.log(`${code}: ${keys.length} keys`);
continue;
}
failed = true;
console.error(`${code}: ${keys.length} keys (expected ${sourceKeys.length})`);
if (missing.length) {
console.error(` missing ${missing.length}: ${missing.join(", ")}`);
annotate(file, `Missing ${missing.length} key(s) present in ${SOURCE}: ${missing.join(", ")}`);
}
if (extra.length) {
console.error(` extra ${extra.length}: ${extra.join(", ")}`);
annotate(file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`);
}
}
// Locale directories present on disk but not declared in _index.json are never
// loaded by the app — surface them so dead translation files don't rot silently.
const onDisk = readdirSync(localesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
const undeclared = onDisk.filter((d) => !declared.includes(d));
if (undeclared.length) {
console.warn(`\n⚠ locale directories not declared in _index.json (not shipped): ${undeclared.join(", ")}`);
}
console.log();
if (failed) {
console.error("Translation check FAILED — every locale must match the English key set.");
process.exit(1);
}
console.log("Translation check passed — all locales match the English key set.");

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"
}
}

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