mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-17 13:11:30 +02:00
Compare commits
24 Commits
revert/com
...
file-share
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a94424d563 | ||
|
|
73cffdb702 | ||
|
|
14aab0fc6e | ||
|
|
f06b8c7624 | ||
|
|
ee3aeadf8f | ||
|
|
c28cf2fa61 | ||
|
|
78c95bb8ec | ||
|
|
1aa1f915a2 | ||
|
|
0bb49fa144 | ||
|
|
ceb1719f9a | ||
|
|
2da4512272 | ||
|
|
16f7e1e148 | ||
|
|
9531c9cf79 | ||
|
|
ba16475ad6 | ||
|
|
a98ced399e | ||
|
|
a8d2e5b0b2 | ||
|
|
cc0702396c | ||
|
|
6a83476831 | ||
|
|
c4c8e2fe1e | ||
|
|
a33e981c26 | ||
|
|
c1c8ee832e | ||
|
|
9ee5c04687 | ||
|
|
26f7ed858d | ||
|
|
82e799f095 |
@@ -110,6 +110,11 @@ type Client struct {
|
||||
|
||||
extendMu sync.Mutex
|
||||
extendCancel context.CancelFunc
|
||||
|
||||
// The file drop handle survives engine restarts so the UI keeps one listener
|
||||
// registration and one history view across reconnects. See fileDropFor.
|
||||
fileDropMu sync.Mutex
|
||||
fileDrop *FileDrop
|
||||
}
|
||||
|
||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
|
||||
@@ -197,6 +202,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)
|
||||
c.attachFileDrop(connectClient, cfgFile)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||
// is authenticated again — release the latch Status() reports from. Clear
|
||||
@@ -238,6 +244,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)
|
||||
c.attachFileDrop(connectClient, cfgFile)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||
}
|
||||
|
||||
78
client/android/client_filedrop.go
Normal file
78
client/android/client_filedrop.go
Normal file
@@ -0,0 +1,78 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
)
|
||||
|
||||
// FileDrop returns the handle of the active profile, creating it on first use.
|
||||
// The UI calls this to list transfers and change settings while disconnected.
|
||||
func (c *Client) FileDrop(configDir string) (*FileDrop, error) {
|
||||
profile, err := NewProfileManager(configDir).GetActiveProfile()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
return c.fileDropFor(configDir, profile.ID)
|
||||
}
|
||||
|
||||
// fileDropFor returns the handle of one profile, replacing the cached one when
|
||||
// the profile changed. The listener is carried over so a profile switch does not
|
||||
// silence the UI.
|
||||
func (c *Client) fileDropFor(configDir, profileID string) (*FileDrop, error) {
|
||||
c.fileDropMu.Lock()
|
||||
|
||||
if c.fileDrop != nil && c.fileDrop.ProfileID() == profileID {
|
||||
fd := c.fileDrop
|
||||
c.fileDropMu.Unlock()
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
fd, err := NewFileDrop(configDir, profileID)
|
||||
if err != nil {
|
||||
c.fileDropMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
ensureFileDropDestination(fd)
|
||||
|
||||
old := c.fileDrop
|
||||
if old != nil {
|
||||
fd.SetListener(old.Listener())
|
||||
}
|
||||
c.fileDrop = fd
|
||||
c.fileDropMu.Unlock()
|
||||
|
||||
// Closing waits out the in-flight uploads of the profile being left, which is
|
||||
// far too long to hold the lock every caller of this goes through.
|
||||
if old != nil {
|
||||
if err := old.Close(); err != nil {
|
||||
log.Warnf("failed to close previous file drop manager: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// attachFileDrop hands the connect client the file drop manager of the profile
|
||||
// the engine is starting for. The profile is derived from the config path rather
|
||||
// than read from the active profile state, so a switch racing the startup cannot
|
||||
// pair one profile's engine with another's transfers. A failure is not fatal:
|
||||
// the tunnel is worth more than the feature, so the engine runs on without it.
|
||||
func (c *Client) attachFileDrop(cc *internal.ConnectClient, cfgFile string) {
|
||||
configDir, profileID, err := profileLocationFor(cfgFile)
|
||||
if err != nil {
|
||||
log.Warnf("file drop is unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fd, err := c.fileDropFor(configDir, profileID)
|
||||
if err != nil {
|
||||
log.Warnf("file drop is unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
cc.SetFileDropManager(fd.manager)
|
||||
}
|
||||
205
client/android/filedrop.go
Normal file
205
client/android/filedrop.go
Normal file
@@ -0,0 +1,205 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
const filedropDataSubdir = "filedrop"
|
||||
|
||||
// FileDrop is the platform-facing handle on one profile's file drop state. It
|
||||
// outlives the engine: the manager keeps policy and history readable while the
|
||||
// tunnel is down, and sending simply fails until it comes back up.
|
||||
type FileDrop struct {
|
||||
mu sync.Mutex
|
||||
configDir string
|
||||
profileID string
|
||||
manager *filedrop.Manager
|
||||
listener FileDropListener
|
||||
}
|
||||
|
||||
// NewFileDrop opens the file drop state of the given profile.
|
||||
func NewFileDrop(configDir, profileID string) (*FileDrop, error) {
|
||||
if configDir == "" || profileID == "" {
|
||||
return nil, errors.New("file drop requires a config dir and profile ID")
|
||||
}
|
||||
|
||||
prefs, err := newProfilePrefs(configDir, profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fd := &FileDrop{configDir: configDir, profileID: profileID}
|
||||
manager, err := filedrop.NewManager(filedrop.ManagerConfig{
|
||||
Profile: profilemanager.ID(profileID),
|
||||
DataDir: filepath.Join(configDir, filedropDataSubdir, profileID),
|
||||
Store: filedrop.NewProfileStore(prefs.prefs),
|
||||
Events: fd.publish,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create file drop manager: %w", err)
|
||||
}
|
||||
|
||||
fd.manager = manager
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// ProfileID returns the profile this handle belongs to.
|
||||
func (f *FileDrop) ProfileID() string {
|
||||
return f.profileID
|
||||
}
|
||||
|
||||
// SetListener installs the event listener, replacing any previous one.
|
||||
func (f *FileDrop) SetListener(listener FileDropListener) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.listener = listener
|
||||
}
|
||||
|
||||
// Listener returns the installed event listener, nil when there is none.
|
||||
func (f *FileDrop) Listener() FileDropListener {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.listener
|
||||
}
|
||||
|
||||
// RemoveListener stops event delivery.
|
||||
func (f *FileDrop) RemoveListener() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.listener = nil
|
||||
}
|
||||
|
||||
// Send starts an asynchronous transfer and returns its local transfer ID.
|
||||
func (f *FileDrop) Send(peerKey, peerName, peerIP string, payloads *FileDropPayloads) (string, error) {
|
||||
if payloads == nil || payloads.Length() == 0 {
|
||||
return "", errors.New("nothing to send")
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(peerIP)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse peer address %q: %w", peerIP, err)
|
||||
}
|
||||
|
||||
id, err := f.manager.Send(filedrop.PeerKey(peerKey), peerName, addr.Unmap(), payloads.items)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(id), nil
|
||||
}
|
||||
|
||||
// Accept releases a pending incoming offer for download.
|
||||
func (f *FileDrop) Accept(transferID string) error {
|
||||
return f.manager.Accept(filedrop.OfferID(transferID))
|
||||
}
|
||||
|
||||
// Decline refuses a pending incoming offer.
|
||||
func (f *FileDrop) Decline(transferID string) error {
|
||||
return f.manager.Decline(filedrop.OfferID(transferID))
|
||||
}
|
||||
|
||||
// Cancel aborts a transfer in either direction.
|
||||
func (f *FileDrop) Cancel(transferID string) {
|
||||
f.manager.Cancel(filedrop.OfferID(transferID))
|
||||
}
|
||||
|
||||
// Transfers returns the history, newest first.
|
||||
func (f *FileDrop) Transfers() *FileDropTransferArray {
|
||||
transfers := f.manager.Transfers()
|
||||
items := make([]*FileDropTransfer, 0, len(transfers))
|
||||
for _, t := range transfers {
|
||||
items = append(items, toFileDropTransfer(t))
|
||||
}
|
||||
return &FileDropTransferArray{items: items}
|
||||
}
|
||||
|
||||
// Transfer returns one history entry, or nil when it is unknown.
|
||||
func (f *FileDrop) Transfer(transferID string) *FileDropTransfer {
|
||||
for _, t := range f.manager.Transfers() {
|
||||
if string(t.ID) == transferID {
|
||||
return toFileDropTransfer(t)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTransfer removes one history entry, cancelling it when still live.
|
||||
func (f *FileDrop) DeleteTransfer(transferID string) {
|
||||
f.manager.DeleteTransfer(filedrop.OfferID(transferID))
|
||||
}
|
||||
|
||||
// Mode returns the base receiving mode.
|
||||
func (f *FileDrop) Mode() int {
|
||||
return int(f.manager.Policy().Get().Mode)
|
||||
}
|
||||
|
||||
// SetMode changes the base receiving mode.
|
||||
func (f *FileDrop) SetMode(mode int) error {
|
||||
return f.manager.Policy().SetMode(filedrop.Mode(mode))
|
||||
}
|
||||
|
||||
// DestinationDir returns the directory received files are delivered to.
|
||||
func (f *FileDrop) DestinationDir() string {
|
||||
return f.manager.DestinationDir()
|
||||
}
|
||||
|
||||
// SetDestinationDir persists the delivery directory. It must be a filesystem
|
||||
// path the app can write; content URIs are not paths, so the platform layer
|
||||
// moves files out of this directory afterwards.
|
||||
func (f *FileDrop) SetDestinationDir(dir string) error {
|
||||
return f.manager.SetDestinationDir(dir)
|
||||
}
|
||||
|
||||
// PeerRule returns the rule stored for one sender.
|
||||
func (f *FileDrop) PeerRule(peerKey string) int {
|
||||
return int(f.manager.Policy().Get().Senders[filedrop.PeerKey(peerKey)])
|
||||
}
|
||||
|
||||
// SetPeerRule sets or clears the exception for one sender.
|
||||
func (f *FileDrop) SetPeerRule(peerKey string, rule int) error {
|
||||
return f.manager.SetSenderRule(filedrop.PeerKey(peerKey), filedrop.SenderRule(rule))
|
||||
}
|
||||
|
||||
// Close stops the receiver and aborts every outgoing transfer.
|
||||
func (f *FileDrop) Close() error {
|
||||
f.RemoveListener()
|
||||
return f.manager.Close()
|
||||
}
|
||||
|
||||
func (f *FileDrop) publish(kind filedrop.EventKind, transfer filedrop.Transfer) {
|
||||
f.mu.Lock()
|
||||
listener := f.listener
|
||||
f.mu.Unlock()
|
||||
if listener == nil {
|
||||
return
|
||||
}
|
||||
listener.OnFileDropEvent(int(kind), toFileDropTransfer(transfer))
|
||||
}
|
||||
|
||||
// defaultFileDropDir is the app-private landing directory used until the
|
||||
// platform layer configures one.
|
||||
func defaultFileDropDir(configDir, profileID string) string {
|
||||
return filepath.Join(configDir, filedropDataSubdir, profileID, "incoming")
|
||||
}
|
||||
|
||||
// ensureFileDropDestination seeds the delivery directory on first use, so a
|
||||
// received file always has somewhere to land.
|
||||
func ensureFileDropDestination(fd *FileDrop) {
|
||||
if fd.DestinationDir() != "" {
|
||||
return
|
||||
}
|
||||
dir := defaultFileDropDir(fd.configDir, fd.profileID)
|
||||
if err := fd.SetDestinationDir(dir); err != nil {
|
||||
log.Warnf("failed to set default file drop destination: %v", err)
|
||||
}
|
||||
}
|
||||
122
client/android/filedrop_payload.go
Normal file
122
client/android/filedrop_payload.go
Normal file
@@ -0,0 +1,122 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
)
|
||||
|
||||
// FileSource opens the bytes of one outgoing item. Android hands out content URIs
|
||||
// rather than paths, so the platform layer owns opening and seeking.
|
||||
type FileSource interface {
|
||||
// Open returns a stream positioned at offset. It is called once per attempt,
|
||||
// and again from the start when a transfer resumes.
|
||||
Open(offset int64) (SourceStream, error)
|
||||
}
|
||||
|
||||
// SourceStream is the readable half of a FileSource.
|
||||
//
|
||||
// It returns each chunk instead of filling a caller-supplied buffer: gomobile
|
||||
// copies a []byte argument into a fresh Java array and never copies it back, so
|
||||
// a fill-my-buffer method would hand back the right length with no data. Only
|
||||
// the return value crosses the bridge intact.
|
||||
type SourceStream interface {
|
||||
// NextChunk returns up to max bytes. An empty result means end of stream.
|
||||
NextChunk(max int) ([]byte, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type sourceStreamReader struct {
|
||||
stream SourceStream
|
||||
buf []byte
|
||||
eof bool
|
||||
}
|
||||
|
||||
// FileDropPayloads collects the items of one outgoing transfer.
|
||||
type FileDropPayloads struct {
|
||||
items []filedrop.Payload
|
||||
}
|
||||
|
||||
// NewFileDropPayloads returns an empty payload list to fill before sending.
|
||||
func NewFileDropPayloads() *FileDropPayloads {
|
||||
return &FileDropPayloads{}
|
||||
}
|
||||
|
||||
// AddFile appends a file item backed by a platform-provided source.
|
||||
func (p *FileDropPayloads) AddFile(name string, size int64, contentType string, source FileSource) error {
|
||||
if name == "" {
|
||||
return errors.New("file name is required")
|
||||
}
|
||||
if source == nil {
|
||||
return fmt.Errorf("file %s has no source", name)
|
||||
}
|
||||
|
||||
p.items = append(p.items, filedrop.Payload{
|
||||
Meta: filedrop.FileMeta{
|
||||
Name: name,
|
||||
Size: size,
|
||||
ContentType: contentType,
|
||||
},
|
||||
Open: func(offset int64) (io.ReadCloser, error) {
|
||||
stream, err := source.Open(offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stream == nil {
|
||||
return nil, fmt.Errorf("no stream for %s", name)
|
||||
}
|
||||
return &sourceStreamReader{stream: stream}, nil
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddText appends an inline text item.
|
||||
func (p *FileDropPayloads) AddText(name, text string) error {
|
||||
if len(text) > filedrop.MaxInlineTextSize {
|
||||
return fmt.Errorf("text exceeds %d bytes", filedrop.MaxInlineTextSize)
|
||||
}
|
||||
if name == "" {
|
||||
name = "text"
|
||||
}
|
||||
p.items = append(p.items, filedrop.TextPayload(name, text))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Length returns the number of items.
|
||||
func (p *FileDropPayloads) Length() int {
|
||||
return len(p.items)
|
||||
}
|
||||
|
||||
func (r *sourceStreamReader) Read(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
for len(r.buf) == 0 {
|
||||
if r.eof {
|
||||
return 0, io.EOF
|
||||
}
|
||||
chunk, err := r.stream.NextChunk(len(p))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(chunk) == 0 {
|
||||
r.eof = true
|
||||
return 0, io.EOF
|
||||
}
|
||||
r.buf = chunk
|
||||
}
|
||||
|
||||
n := copy(p, r.buf)
|
||||
r.buf = r.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *sourceStreamReader) Close() error {
|
||||
return r.stream.Close()
|
||||
}
|
||||
261
client/android/filedrop_test.go
Normal file
261
client/android/filedrop_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
type stubStream struct {
|
||||
reader io.Reader
|
||||
closed bool
|
||||
// chunk caps what one call returns, so the reader's buffering is exercised
|
||||
// rather than every read landing in a single hop.
|
||||
chunk int
|
||||
}
|
||||
|
||||
type stubSource struct {
|
||||
content string
|
||||
offsets []int64
|
||||
chunk int
|
||||
}
|
||||
|
||||
func (s *stubStream) NextChunk(max int) ([]byte, error) {
|
||||
if s.chunk > 0 && s.chunk < max {
|
||||
max = s.chunk
|
||||
}
|
||||
buf := make([]byte, max)
|
||||
|
||||
n, err := s.reader.Read(buf)
|
||||
if errors.Is(err, io.EOF) || n == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf[:n], nil
|
||||
}
|
||||
|
||||
func (s *stubStream) Close() error {
|
||||
s.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubSource) Open(offset int64) (SourceStream, error) {
|
||||
s.offsets = append(s.offsets, offset)
|
||||
return &stubStream{reader: strings.NewReader(s.content[offset:]), chunk: s.chunk}, nil
|
||||
}
|
||||
|
||||
func TestPayloadSourceReassemblesChunks(t *testing.T) {
|
||||
for name, chunk := range map[string]int{
|
||||
"one hop": 0,
|
||||
"three bytes": 3,
|
||||
"one byte": 1,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
source := &stubSource{content: "hello world", chunk: chunk}
|
||||
|
||||
payloads := NewFileDropPayloads()
|
||||
if err := payloads.AddFile("greeting.txt", 11, "text/plain", source); err != nil {
|
||||
t.Fatalf("AddFile: %v", err)
|
||||
}
|
||||
if payloads.Length() != 1 {
|
||||
t.Fatalf("expected 1 payload, got %d", payloads.Length())
|
||||
}
|
||||
|
||||
stream, err := payloads.items[0].Open(0)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
if string(got) != "hello world" {
|
||||
t.Fatalf("got %q, want %q", got, "hello world")
|
||||
}
|
||||
if err := stream.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadSourceHonoursOffset(t *testing.T) {
|
||||
source := &stubSource{content: "hello world"}
|
||||
|
||||
payloads := NewFileDropPayloads()
|
||||
if err := payloads.AddFile("greeting.txt", 11, "", source); err != nil {
|
||||
t.Fatalf("AddFile: %v", err)
|
||||
}
|
||||
|
||||
stream, err := payloads.items[0].Open(6)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
got, err := io.ReadAll(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
if string(got) != "world" {
|
||||
t.Fatalf("got %q, want %q", got, "world")
|
||||
}
|
||||
if len(source.offsets) != 1 || source.offsets[0] != 6 {
|
||||
t.Fatalf("expected one open at offset 6, got %v", source.offsets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadRejectsMissingSourceAndOversizedText(t *testing.T) {
|
||||
payloads := NewFileDropPayloads()
|
||||
|
||||
if err := payloads.AddFile("no-source.bin", 1, "", nil); err == nil {
|
||||
t.Fatal("expected an error for a file without a source")
|
||||
}
|
||||
if err := payloads.AddFile("", 1, "", &stubSource{}); err == nil {
|
||||
t.Fatal("expected an error for an empty file name")
|
||||
}
|
||||
if err := payloads.AddText("big", strings.Repeat("x", filedrop.MaxInlineTextSize+1)); err == nil {
|
||||
t.Fatal("expected an error for oversized text")
|
||||
}
|
||||
if payloads.Length() != 0 {
|
||||
t.Fatalf("expected no payloads, got %d", payloads.Length())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDropPersistsSettingsPerProfile(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeTestProfile(t, configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
writeTestProfile(t, configDir, "11111111222222223333333344444444")
|
||||
|
||||
first, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileDrop: %v", err)
|
||||
}
|
||||
defer first.Close()
|
||||
|
||||
if err := first.SetMode(FileDropModeAutoAccept); err != nil {
|
||||
t.Fatalf("SetMode: %v", err)
|
||||
}
|
||||
if err := first.SetPeerRule("peer-key", FileDropRuleBlock); err != nil {
|
||||
t.Fatalf("SetPeerRule: %v", err)
|
||||
}
|
||||
|
||||
second, err := NewFileDrop(configDir, "11111111222222223333333344444444")
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileDrop: %v", err)
|
||||
}
|
||||
defer second.Close()
|
||||
|
||||
if got := second.Mode(); got != FileDropModeAsk {
|
||||
t.Fatalf("second profile mode = %d, want the default %d", got, FileDropModeAsk)
|
||||
}
|
||||
if got := second.PeerRule("peer-key"); got != FileDropRuleDefault {
|
||||
t.Fatalf("second profile rule = %d, want %d", got, FileDropRuleDefault)
|
||||
}
|
||||
|
||||
reopened, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileDrop: %v", err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
|
||||
if got := reopened.Mode(); got != FileDropModeAutoAccept {
|
||||
t.Fatalf("reopened mode = %d, want %d", got, FileDropModeAutoAccept)
|
||||
}
|
||||
if got := reopened.PeerRule("peer-key"); got != FileDropRuleBlock {
|
||||
t.Fatalf("reopened rule = %d, want %d", got, FileDropRuleBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDropSeedsDefaultDestination(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeTestProfile(t, configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
|
||||
fd, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileDrop: %v", err)
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
if fd.DestinationDir() != "" {
|
||||
t.Fatalf("expected no destination before seeding, got %q", fd.DestinationDir())
|
||||
}
|
||||
|
||||
ensureFileDropDestination(fd)
|
||||
|
||||
want := filepath.Join(configDir, filedropDataSubdir, "aaaaaaaabbbbbbbbccccccccdddddddd", "incoming")
|
||||
if got := fd.DestinationDir(); got != want {
|
||||
t.Fatalf("destination = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDropSendWithoutTunnelFails(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeTestProfile(t, configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
|
||||
fd, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileDrop: %v", err)
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
payloads := NewFileDropPayloads()
|
||||
if err := payloads.AddText("note", "hi"); err != nil {
|
||||
t.Fatalf("AddText: %v", err)
|
||||
}
|
||||
|
||||
if _, err := fd.Send("peer-key", "peer", "100.64.0.2", payloads); !errors.Is(err, filedrop.ErrNotConnected) {
|
||||
t.Fatalf("Send error = %v, want %v", err, filedrop.ErrNotConnected)
|
||||
}
|
||||
if _, err := fd.Send("peer-key", "peer", "100.64.0.2", NewFileDropPayloads()); err == nil {
|
||||
t.Fatal("expected an error when there is nothing to send")
|
||||
}
|
||||
if _, err := fd.Send("peer-key", "peer", "not-an-ip", payloads); err == nil {
|
||||
t.Fatal("expected an error for an unparseable peer address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileLocationForSplitsConfigPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
dir, id, err := profileLocationFor(filepath.Join(root, defaultConfigFilename))
|
||||
if err != nil {
|
||||
t.Fatalf("default profile: %v", err)
|
||||
}
|
||||
if dir != root || id != profilemanager.DefaultProfileName {
|
||||
t.Fatalf("default profile = (%q, %q), want (%q, %q)", dir, id, root, profilemanager.DefaultProfileName)
|
||||
}
|
||||
|
||||
named := filepath.Join(root, profilesSubdir, "aaaaaaaabbbbbbbbccccccccdddddddd.json")
|
||||
dir, id, err = profileLocationFor(named)
|
||||
if err != nil {
|
||||
t.Fatalf("named profile: %v", err)
|
||||
}
|
||||
if dir != root || id != "aaaaaaaabbbbbbbbccccccccdddddddd" {
|
||||
t.Fatalf("named profile = (%q, %q), want (%q, %q)", dir, id, root, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
}
|
||||
|
||||
for _, path := range []string{"", filepath.Join(root, "stray.json"), filepath.Join(root, profilesSubdir, "not-an-id!.json")} {
|
||||
if _, _, err := profileLocationFor(path); err == nil {
|
||||
t.Fatalf("expected an error for %q", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestProfile(t *testing.T, configDir, id string) {
|
||||
t.Helper()
|
||||
|
||||
pm := NewProfileManager(configDir)
|
||||
if _, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(id), androidUsername); err != nil {
|
||||
t.Fatalf("resolve prefs for %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
163
client/android/filedrop_transfer.go
Normal file
163
client/android/filedrop_transfer.go
Normal file
@@ -0,0 +1,163 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
)
|
||||
|
||||
// The file drop receiving modes exported via gomobile.
|
||||
const (
|
||||
FileDropModeOff = int(filedrop.ModeOff)
|
||||
FileDropModeAsk = int(filedrop.ModeAsk)
|
||||
FileDropModeAutoAccept = int(filedrop.ModeAutoAccept)
|
||||
)
|
||||
|
||||
// The per-sender rules exported via gomobile.
|
||||
const (
|
||||
FileDropRuleDefault = int(filedrop.SenderRuleDefault)
|
||||
FileDropRuleAlwaysAccept = int(filedrop.SenderRuleAlwaysAccept)
|
||||
FileDropRuleBlock = int(filedrop.SenderRuleBlock)
|
||||
)
|
||||
|
||||
// The transfer states exported via gomobile.
|
||||
const (
|
||||
FileDropStatePending = int(filedrop.StatePending)
|
||||
FileDropStateTransferring = int(filedrop.StateTransferring)
|
||||
FileDropStateCompleted = int(filedrop.StateCompleted)
|
||||
FileDropStateDeclined = int(filedrop.StateDeclined)
|
||||
FileDropStateExpired = int(filedrop.StateExpired)
|
||||
FileDropStateCancelled = int(filedrop.StateCancelled)
|
||||
FileDropStateFailed = int(filedrop.StateFailed)
|
||||
)
|
||||
|
||||
// The failure reasons exported via gomobile.
|
||||
const (
|
||||
FileDropReasonNone = int(filedrop.ReasonNone)
|
||||
FileDropReasonUnreachable = int(filedrop.ReasonUnreachable)
|
||||
)
|
||||
|
||||
// The event kinds delivered to a FileDropListener.
|
||||
const (
|
||||
FileDropEventOffer = int(filedrop.EventOffer)
|
||||
FileDropEventCompleted = int(filedrop.EventCompleted)
|
||||
FileDropEventFailed = int(filedrop.EventFailed)
|
||||
FileDropEventWithdrawn = int(filedrop.EventWithdrawn)
|
||||
)
|
||||
|
||||
// FileDropListener receives transfer events. Calls arrive on background
|
||||
// goroutines, so implementations must post to the UI thread themselves.
|
||||
type FileDropListener interface {
|
||||
OnFileDropEvent(kind int, transfer *FileDropTransfer)
|
||||
}
|
||||
|
||||
// FileDropFile is one item of a transfer.
|
||||
type FileDropFile struct {
|
||||
Name string
|
||||
Size int64
|
||||
ContentType string
|
||||
IsText bool
|
||||
Text string
|
||||
}
|
||||
|
||||
// FileDropTransfer is one history entry.
|
||||
type FileDropTransfer struct {
|
||||
ID string
|
||||
Outgoing bool
|
||||
PeerKey string
|
||||
PeerName string
|
||||
State int
|
||||
Transferred int64
|
||||
TotalSize int64
|
||||
// Unix milliseconds, so the platform layer can render the time in the
|
||||
// user's own locale and zone rather than parsing a preformatted string.
|
||||
CreatedAtMillis int64
|
||||
UpdatedAtMillis int64
|
||||
// IsText marks a transfer that is a single inline snippet rather than
|
||||
// files, so the UI can drop the size and offer a copy action instead.
|
||||
IsText bool
|
||||
Error string
|
||||
Reason int
|
||||
|
||||
files []*FileDropFile
|
||||
deliveredPaths []string
|
||||
}
|
||||
|
||||
// FileDropTransferArray wraps transfers for gomobile compatibility.
|
||||
type FileDropTransferArray struct {
|
||||
items []*FileDropTransfer
|
||||
}
|
||||
|
||||
// FileCount returns the number of items in the transfer.
|
||||
func (t *FileDropTransfer) FileCount() int {
|
||||
return len(t.files)
|
||||
}
|
||||
|
||||
// GetFile returns the item at index i, or nil when out of range.
|
||||
func (t *FileDropTransfer) GetFile(i int) *FileDropFile {
|
||||
if i < 0 || i >= len(t.files) {
|
||||
return nil
|
||||
}
|
||||
return t.files[i]
|
||||
}
|
||||
|
||||
// DeliveredPaths returns the delivered file paths joined by newlines, so the
|
||||
// platform layer can move them into user-visible storage.
|
||||
func (t *FileDropTransfer) DeliveredPaths() string {
|
||||
return strings.Join(t.deliveredPaths, "\n")
|
||||
}
|
||||
|
||||
// Length returns the number of transfers.
|
||||
func (a *FileDropTransferArray) Length() int {
|
||||
return len(a.items)
|
||||
}
|
||||
|
||||
// Get returns the transfer at index i, or nil when out of range.
|
||||
func (a *FileDropTransferArray) Get(i int) *FileDropTransfer {
|
||||
if i < 0 || i >= len(a.items) {
|
||||
return nil
|
||||
}
|
||||
return a.items[i]
|
||||
}
|
||||
|
||||
func toFileDropTransfer(t filedrop.Transfer) *FileDropTransfer {
|
||||
files := make([]*FileDropFile, 0, len(t.Files))
|
||||
for _, f := range t.Files {
|
||||
files = append(files, &FileDropFile{
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
ContentType: f.ContentType,
|
||||
IsText: f.Kind == filedrop.KindText,
|
||||
Text: f.Text,
|
||||
})
|
||||
}
|
||||
|
||||
return &FileDropTransfer{
|
||||
ID: string(t.ID),
|
||||
Outgoing: t.Direction == filedrop.DirectionSent,
|
||||
PeerKey: string(t.PeerKey),
|
||||
PeerName: t.PeerName,
|
||||
State: int(t.State),
|
||||
Transferred: t.Transferred,
|
||||
TotalSize: t.TotalSize,
|
||||
CreatedAtMillis: unixMillis(t.CreatedAt),
|
||||
UpdatedAtMillis: unixMillis(t.UpdatedAt),
|
||||
IsText: len(t.Files) == 1 && t.Files[0].Kind == filedrop.KindText,
|
||||
Error: t.Error,
|
||||
Reason: int(t.Reason),
|
||||
files: files,
|
||||
deliveredPaths: t.DeliveredPaths,
|
||||
}
|
||||
}
|
||||
|
||||
// unixMillis renders a timestamp for the platform layer, mapping the zero time
|
||||
// to 0 so it reads as "unknown" rather than as 1970.
|
||||
func unixMillis(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.UnixMilli()
|
||||
}
|
||||
@@ -191,40 +191,49 @@ 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)
|
||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, profileLoginHint(a.cfgPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||
}
|
||||
|
||||
// An empty hint is deliberate, not a fallback: a fresh profile leaves the
|
||||
// choice to the IdP. Switching accounts is done by switching or removing
|
||||
// profiles, not by logging out — logout keeps the email.
|
||||
if a.cfgPath != "" {
|
||||
if hint := readProfileEmail(a.cfgPath); hint != "" {
|
||||
if setter, ok := oAuthFlow.(loginHintSetter); ok {
|
||||
setter.SetLoginHint(hint)
|
||||
}
|
||||
}
|
||||
return 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 ""
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request auth info: %w", err)
|
||||
}
|
||||
|
||||
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||
urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
|
||||
|
||||
if onWaiting != nil {
|
||||
onWaiting()
|
||||
}
|
||||
|
||||
go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
|
||||
|
||||
tokenInfo, err := oAuthFlow.WaitToken(a.ctx, flowInfo)
|
||||
tokenInfo, err := flow.WaitToken(ctx, flowInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("waiting for browser login failed: %v", err)
|
||||
return nil, fmt.Errorf("wait for token: %w", err)
|
||||
}
|
||||
|
||||
return &tokenInfo, nil
|
||||
|
||||
38
client/android/profile_prefs.go
Normal file
38
client/android/profile_prefs.go
Normal file
@@ -0,0 +1,38 @@
|
||||
//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)
|
||||
}
|
||||
@@ -48,6 +48,33 @@ func profileAccountPathFor(configPath string) (string, error) {
|
||||
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
|
||||
}
|
||||
|
||||
// profileLocationFor splits a profile's config path back into the config dir and
|
||||
// the profile ID: <dir>/netbird.cfg is the default profile, while
|
||||
// <dir>/profiles/<id>.json is a named one.
|
||||
func profileLocationFor(configPath string) (string, string, error) {
|
||||
if configPath == "" {
|
||||
return "", "", fmt.Errorf("empty config path")
|
||||
}
|
||||
|
||||
base := filepath.Base(configPath)
|
||||
dir := filepath.Dir(configPath)
|
||||
|
||||
if base == defaultConfigFilename {
|
||||
return dir, profilemanager.DefaultProfileName, nil
|
||||
}
|
||||
|
||||
if filepath.Base(dir) != profilesSubdir {
|
||||
return "", "", fmt.Errorf("config path %q is outside the profiles directory", configPath)
|
||||
}
|
||||
|
||||
id := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", "", fmt.Errorf("config path %q has no valid profile ID", configPath)
|
||||
}
|
||||
|
||||
return filepath.Dir(dir), id, nil
|
||||
}
|
||||
|
||||
// readProfileEmail returns the account email stored for the profile whose config
|
||||
// lives at configPath. A missing or unreadable file yields "", which leaves the
|
||||
// account choice to the IdP.
|
||||
|
||||
649
client/android/ssh_client.go
Normal file
649
client/android/ssh_client.go
Normal file
@@ -0,0 +1,649 @@
|
||||
//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")
|
||||
}
|
||||
168
client/android/ssh_known_hosts.go
Normal file
168
client/android/ssh_known_hosts.go
Normal file
@@ -0,0 +1,168 @@
|
||||
//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, §ion); 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
|
||||
}
|
||||
104
client/android/ssh_sessions.go
Normal file
104
client/android/ssh_sessions.go
Normal file
@@ -0,0 +1,104 @@
|
||||
//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, §ion); 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})
|
||||
}
|
||||
@@ -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"
|
||||
sshcommon "github.com/netbirdio/netbird/client/ssh"
|
||||
nbssh "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,12 +521,7 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
storedKey, found := engine.GetPeerSSHKey(peerAddress)
|
||||
if !found {
|
||||
return sshcommon.ErrPeerNotFound
|
||||
}
|
||||
|
||||
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
||||
return nbssh.PeerKeyLookup(engine.GetPeerSSHKey).VerifySSHHostKey(peerAddress, key)
|
||||
}
|
||||
|
||||
// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool
|
||||
|
||||
@@ -138,26 +138,37 @@ 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) (OAuthFlow, error) {
|
||||
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint string) (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 {
|
||||
flow, err = a.getDeviceFlow(client)
|
||||
return err
|
||||
deviceFlow, err := a.getDeviceFlow(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deviceFlow.SetLoginHint(hint)
|
||||
flow = deviceFlow
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try PKCE flow first
|
||||
flow, err = a.getPKCEFlow(client)
|
||||
pkceFlow, 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) {
|
||||
flow, err = a.getDeviceFlow(client)
|
||||
return err
|
||||
deviceFlow, err := a.getDeviceFlow(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deviceFlow.SetLoginHint(hint)
|
||||
flow = deviceFlow
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
pkceFlow.SetLoginHint(hint)
|
||||
flow = pkceFlow
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
@@ -97,9 +97,7 @@ func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config
|
||||
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
|
||||
}
|
||||
|
||||
if hint != "" {
|
||||
pkceFlowInfo.SetLoginHint(hint)
|
||||
}
|
||||
pkceFlowInfo.SetLoginHint(hint)
|
||||
|
||||
return pkceFlowInfo, nil
|
||||
}
|
||||
@@ -127,9 +125,7 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.
|
||||
}
|
||||
}
|
||||
|
||||
if hint != "" {
|
||||
deviceFlowInfo.SetLoginHint(hint)
|
||||
}
|
||||
deviceFlowInfo.SetLoginHint(hint)
|
||||
|
||||
return deviceFlowInfo, nil
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
@@ -64,10 +65,11 @@ type ConnectClient struct {
|
||||
config *profilemanager.Config
|
||||
statusRecorder *peer.Status
|
||||
|
||||
engine *Engine
|
||||
engineMutex sync.Mutex
|
||||
clientMetrics *metrics.ClientMetrics
|
||||
updateManager *updater.Manager
|
||||
engine *Engine
|
||||
engineMutex sync.Mutex
|
||||
clientMetrics *metrics.ClientMetrics
|
||||
updateManager *updater.Manager
|
||||
fileDropManager *filedrop.Manager
|
||||
|
||||
persistSyncResponse bool
|
||||
}
|
||||
@@ -95,6 +97,12 @@ func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
|
||||
c.updateManager = um
|
||||
}
|
||||
|
||||
// SetFileDropManager hands the engine the active profile's file drop manager, so
|
||||
// the transfer receiver starts and stops with the tunnel. Must be set before Run.
|
||||
func (c *ConnectClient) SetFileDropManager(m *filedrop.Manager) {
|
||||
c.fileDropManager = m
|
||||
}
|
||||
|
||||
// Run with main logic.
|
||||
func (c *ConnectClient) Run(runningChan chan struct{}, logPath string) error {
|
||||
if androidRunOverride != nil {
|
||||
@@ -424,6 +432,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
UpdateManager: c.updateManager,
|
||||
ClientMetrics: c.clientMetrics,
|
||||
MetricsCtx: c.ctx,
|
||||
FileDrop: c.fileDropManager,
|
||||
}, mobileDependency)
|
||||
engine.SetSyncResponsePersistence(c.persistSyncResponse)
|
||||
c.engine = engine
|
||||
|
||||
@@ -40,6 +40,7 @@ import (
|
||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||
"github.com/netbirdio/netbird/client/internal/dnsfwd"
|
||||
"github.com/netbirdio/netbird/client/internal/expose"
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
"github.com/netbirdio/netbird/client/internal/ingressgw"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
@@ -181,6 +182,7 @@ type EngineServices struct {
|
||||
UpdateManager *updater.Manager
|
||||
ClientMetrics *metrics.ClientMetrics
|
||||
MetricsCtx context.Context
|
||||
FileDrop *filedrop.Manager
|
||||
}
|
||||
|
||||
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
|
||||
@@ -236,6 +238,10 @@ type Engine struct {
|
||||
|
||||
sshServer sshServer
|
||||
|
||||
fileDrop *filedrop.Manager
|
||||
fileDropRunning bool
|
||||
fileDropPort uint16
|
||||
|
||||
statusRecorder *peer.Status
|
||||
|
||||
firewall firewallManager.Manager
|
||||
@@ -350,6 +356,7 @@ func NewEngine(
|
||||
metricsCtx: services.MetricsCtx,
|
||||
updateManager: services.UpdateManager,
|
||||
syncStoreDir: config.StateDir,
|
||||
fileDrop: services.FileDrop,
|
||||
}
|
||||
// sessionWatcher keeps the SubscribeStatus consumers in sync with the
|
||||
// session expiry deadline. Deadline-change ticks come for free via
|
||||
@@ -415,6 +422,8 @@ func (e *Engine) stopLocked() {
|
||||
log.Warnf("failed to stop SSH server: %v", err)
|
||||
}
|
||||
|
||||
e.stopFileDrop()
|
||||
|
||||
e.cleanupSSHConfig()
|
||||
|
||||
if e.ingressGatewayMgr != nil {
|
||||
@@ -1293,6 +1302,8 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
e.startFileDrop()
|
||||
|
||||
state := e.statusRecorder.GetLocalPeerState()
|
||||
state.IP = e.wgInterface.Address().String()
|
||||
state.IPv6 = e.wgInterface.Address().IPv6String()
|
||||
@@ -1960,6 +1971,8 @@ func (e *Engine) receiveSignalEvents() error {
|
||||
return err
|
||||
}
|
||||
|
||||
e.recordFiledropPort(msg.Key, msg.GetBody().GetFiledropPort())
|
||||
|
||||
log.Debugf("receiveMSG: took %s to get lock for peer %s with session id %s", gotLock, msg.Key, offerAnswer.SessionID)
|
||||
|
||||
if msg.Body.Type == sProto.Body_OFFER {
|
||||
@@ -2439,6 +2452,8 @@ func (e *Engine) GetWgV6Addr() netip.Addr {
|
||||
return e.wgInterface.Address().IPv6
|
||||
}
|
||||
|
||||
// RenewTun swaps the tunnel device for the one behind fd, which the platform
|
||||
// hands over whenever it re-establishes the interface.
|
||||
func (e *Engine) RenewTun(fd int) error {
|
||||
e.syncMsgMux.Lock()
|
||||
wgInterface := e.wgInterface
|
||||
@@ -2448,7 +2463,12 @@ func (e *Engine) RenewTun(fd int) error {
|
||||
return fmt.Errorf("wireguard interface not initialized")
|
||||
}
|
||||
|
||||
return wgInterface.RenewTun(fd)
|
||||
if err := wgInterface.RenewTun(fd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.restartFileDrop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag
|
||||
|
||||
143
client/internal/engine_filedrop.go
Normal file
143
client/internal/engine_filedrop.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
type filedropResolver struct {
|
||||
status *peer.Status
|
||||
}
|
||||
|
||||
// ResolvePeer implements filedrop.PeerResolver.
|
||||
func (r filedropResolver) ResolvePeer(addr netip.Addr) (filedrop.PeerKey, string, bool) {
|
||||
state, ok := r.status.PeerStateByIP(addr.String())
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return filedrop.PeerKey(state.PubKey), state.FQDN, true
|
||||
}
|
||||
|
||||
func (e *Engine) startFileDrop() {
|
||||
if e.fileDrop == nil || e.fileDropRunning || e.wgInterface == nil {
|
||||
return
|
||||
}
|
||||
if e.config.BlockInbound {
|
||||
log.Info("file drop receiver is disabled because inbound connections are blocked")
|
||||
e.setFileDropTunnel()
|
||||
return
|
||||
}
|
||||
|
||||
wgAddr := e.wgInterface.Address()
|
||||
addr := netip.AddrPortFrom(wgAddr.IP, filedrop.Port)
|
||||
resolver := filedropResolver{status: e.statusRecorder}
|
||||
|
||||
netstackNet := e.wgInterface.GetNet()
|
||||
if err := e.fileDrop.StartReceiver(e.ctx, addr, netstackNet, resolver); err != nil {
|
||||
log.Errorf("failed to start file drop receiver: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
bound := e.fileDrop.ReceiverPort()
|
||||
if bound == 0 {
|
||||
bound = filedrop.Port
|
||||
}
|
||||
e.fileDropPort = bound
|
||||
|
||||
if v6 := wgAddr.IPv6; v6.IsValid() {
|
||||
if err := e.fileDrop.AddReceiverListener(e.ctx, netip.AddrPortFrom(v6, bound)); err != nil {
|
||||
log.Warnf("failed to add IPv6 file drop listener: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if netstackNet != nil {
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
RegisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.RegisterNetstackService(nftypes.TCP, bound)
|
||||
}
|
||||
}
|
||||
|
||||
if bound != filedrop.Port {
|
||||
e.signaler.SetFiledropPort(bound)
|
||||
}
|
||||
|
||||
e.setFileDropTunnel()
|
||||
e.fileDropRunning = true
|
||||
}
|
||||
|
||||
// recordFiledropPort stores the file drop port a peer advertised over signaling;
|
||||
// a value that does not fit a port is treated as the default.
|
||||
func (e *Engine) recordFiledropPort(peerKey string, port uint32) {
|
||||
if e.fileDrop == nil {
|
||||
return
|
||||
}
|
||||
if port > 65535 {
|
||||
port = 0
|
||||
}
|
||||
e.fileDrop.Ports().Set(filedrop.PeerKey(peerKey), uint16(port))
|
||||
}
|
||||
|
||||
func (e *Engine) setFileDropTunnel() {
|
||||
var dial filedrop.DialFunc
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
dial = func(ctx context.Context, _, addr string) (net.Conn, error) {
|
||||
addrPort, err := netip.ParseAddrPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return netstackNet.DialContextTCPAddrPort(ctx, addrPort)
|
||||
}
|
||||
} else {
|
||||
dialer := &net.Dialer{}
|
||||
dial = dialer.DialContext
|
||||
}
|
||||
|
||||
e.fileDrop.SetTunnel(dial, e.statusRecorder.GetLocalPeerState().FQDN)
|
||||
}
|
||||
|
||||
// restartFileDrop rebinds the receiver after the platform replaced the tunnel
|
||||
// device. The listeners are bound to the overlay address of the interface being
|
||||
// swapped out and do not survive it: Android renews the tun on every route
|
||||
// change, which leaves the IPv4 listener dead with accept4: invalid argument.
|
||||
func (e *Engine) restartFileDrop() {
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
|
||||
if e.fileDrop == nil || !e.fileDropRunning || e.wgInterface == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.stopFileDrop()
|
||||
e.startFileDrop()
|
||||
}
|
||||
|
||||
func (e *Engine) stopFileDrop() {
|
||||
if e.fileDrop == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if e.fileDropRunning {
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
UnregisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.UnregisterNetstackService(nftypes.TCP, e.fileDropPort)
|
||||
}
|
||||
}
|
||||
e.signaler.SetFiledropPort(0)
|
||||
}
|
||||
|
||||
if err := e.fileDrop.StopReceiver(); err != nil {
|
||||
log.Warnf("failed to stop file drop receiver: %v", err)
|
||||
}
|
||||
e.fileDropRunning = false
|
||||
e.fileDropPort = 0
|
||||
}
|
||||
446
client/internal/filedrop/client.go
Normal file
446
client/internal/filedrop/client.go
Normal file
@@ -0,0 +1,446 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPollTimeout = 60 * time.Second
|
||||
defaultOfferTimeout = DefaultOfferTTL
|
||||
uploadRetryDelay = 2 * time.Second
|
||||
maxUploadAttempts = 3
|
||||
)
|
||||
|
||||
// DialFunc opens a connection to the receiving peer over the tunnel.
|
||||
type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// Payload is one item to send; Open is called per attempt starting at an offset.
|
||||
type Payload struct {
|
||||
Meta FileMeta
|
||||
Open func(offset int64) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// ProgressFunc reports staged bytes for one item as the upload streams.
|
||||
type ProgressFunc func(index int, sent int64, total int64)
|
||||
|
||||
// ClientConfig configures the sending side.
|
||||
type ClientConfig struct {
|
||||
Dial DialFunc
|
||||
SenderName string
|
||||
PollTimeout time.Duration
|
||||
OfferTimeout time.Duration
|
||||
}
|
||||
|
||||
type progressReader struct {
|
||||
r io.Reader
|
||||
sent int64
|
||||
total int64
|
||||
report func(sent int64)
|
||||
}
|
||||
|
||||
// Client sends offers and payloads to a peer's file drop service.
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
senderName string
|
||||
pollTimeout time.Duration
|
||||
offerTimeout time.Duration
|
||||
}
|
||||
|
||||
func (p *progressReader) Read(b []byte) (int, error) {
|
||||
n, err := p.r.Read(b)
|
||||
if n > 0 {
|
||||
p.sent += int64(n)
|
||||
p.report(p.sent)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// NewClient builds a sending client over the given dialer.
|
||||
func NewClient(cfg ClientConfig) (*Client, error) {
|
||||
if cfg.Dial == nil {
|
||||
return nil, errors.New("dial function is required")
|
||||
}
|
||||
|
||||
pollTimeout := cfg.PollTimeout
|
||||
if pollTimeout <= 0 {
|
||||
pollTimeout = defaultPollTimeout
|
||||
}
|
||||
offerTimeout := cfg.OfferTimeout
|
||||
if offerTimeout <= 0 {
|
||||
offerTimeout = defaultOfferTimeout
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return cfg.Dial(ctx, network, addr) },
|
||||
MaxIdleConnsPerHost: 2,
|
||||
ResponseHeaderTimeout: pollTimeout + 30*time.Second,
|
||||
}
|
||||
|
||||
return &Client{
|
||||
http: &http.Client{Transport: transport},
|
||||
senderName: cfg.SenderName,
|
||||
pollTimeout: pollTimeout,
|
||||
offerTimeout: offerTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TextPayload builds an inline text payload, which is carried in the offer itself.
|
||||
func TextPayload(name, text string) Payload {
|
||||
return Payload{
|
||||
Meta: FileMeta{
|
||||
Name: name,
|
||||
Size: int64(len(text)),
|
||||
ContentType: "text/plain",
|
||||
Kind: KindText,
|
||||
Text: text,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Send offers the payloads to the peer at addr and uploads them once accepted.
|
||||
func (c *Client) Send(ctx context.Context, addr netip.AddrPort, payloads []Payload, progress ProgressFunc) (OfferID, error) {
|
||||
id, decision, err := c.Offer(ctx, addr, payloads)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
|
||||
decision, err = c.AwaitDecision(ctx, addr, id, decision)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
if err := decisionError(decision); err != nil {
|
||||
return id, err
|
||||
}
|
||||
|
||||
return id, c.Upload(ctx, addr, id, payloads, progress)
|
||||
}
|
||||
|
||||
// Offer announces the payloads and returns the offer ID with its initial decision.
|
||||
func (c *Client) Offer(ctx context.Context, addr netip.AddrPort, payloads []Payload) (OfferID, Decision, error) {
|
||||
if len(payloads) == 0 {
|
||||
return "", DecisionPending, fmt.Errorf("%w: no payloads", ErrInvalidOffer)
|
||||
}
|
||||
return c.postOffer(ctx, baseURL(addr), payloads)
|
||||
}
|
||||
|
||||
// AwaitDecision resolves a pending decision by long-polling the receiver.
|
||||
func (c *Client) AwaitDecision(ctx context.Context, addr netip.AddrPort, id OfferID, decision Decision) (Decision, error) {
|
||||
if decision != DecisionPending {
|
||||
return decision, nil
|
||||
}
|
||||
return c.awaitDecision(ctx, baseURL(addr), id)
|
||||
}
|
||||
|
||||
// Upload streams every non-inline payload of an accepted offer.
|
||||
func (c *Client) Upload(ctx context.Context, addr netip.AddrPort, id OfferID, payloads []Payload, progress ProgressFunc) error {
|
||||
base := baseURL(addr)
|
||||
for i, p := range payloads {
|
||||
if p.Meta.Kind == KindText {
|
||||
continue
|
||||
}
|
||||
if err := c.uploadFile(ctx, base, id, i, p, progress); err != nil {
|
||||
return fmt.Errorf("upload %s: %w", p.Meta.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel withdraws an offer, taking the receiver's consent prompt with it.
|
||||
func (c *Client) Cancel(ctx context.Context, addr netip.AddrPort, id OfferID) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, offerURL(baseURL(addr), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build cancel request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send cancel: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp)
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
|
||||
return statusError(resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) postOffer(ctx context.Context, base string, payloads []Payload) (OfferID, Decision, error) {
|
||||
files := make([]FileMeta, len(payloads))
|
||||
for i, p := range payloads {
|
||||
files[i] = p.Meta
|
||||
}
|
||||
|
||||
body, err := json.Marshal(OfferRequest{SenderName: c.senderName, Files: files})
|
||||
if err != nil {
|
||||
return "", DecisionPending, fmt.Errorf("encode offer: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+pathOffers, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", DecisionPending, fmt.Errorf("build offer request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", DecisionPending, fmt.Errorf("send offer: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp)
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusCreated, http.StatusAccepted:
|
||||
case http.StatusForbidden:
|
||||
return "", DecisionPending, ErrRefused
|
||||
default:
|
||||
return "", DecisionPending, statusError(resp)
|
||||
}
|
||||
|
||||
var offer OfferResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxOfferBodySize)).Decode(&offer); err != nil {
|
||||
return "", DecisionPending, fmt.Errorf("decode offer response: %w", err)
|
||||
}
|
||||
if offer.ID == "" {
|
||||
return "", DecisionPending, fmt.Errorf("%w: receiver returned no offer id", ErrInvalidOffer)
|
||||
}
|
||||
if !offer.Decision.valid() {
|
||||
return "", DecisionPending, fmt.Errorf("%w: receiver returned decision %s", ErrInvalidOffer, offer.Decision)
|
||||
}
|
||||
|
||||
return offer.ID, offer.Decision, nil
|
||||
}
|
||||
|
||||
func (c *Client) awaitDecision(ctx context.Context, base string, id OfferID) (Decision, error) {
|
||||
deadline := time.Now().Add(c.offerTimeout)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
decision, err := c.pollDecision(ctx, base, id)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return DecisionPending, ctx.Err()
|
||||
}
|
||||
log.Debugf("poll file drop decision: %v", err)
|
||||
if !sleepCtx(ctx, uploadRetryDelay) {
|
||||
return DecisionPending, ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if decision != DecisionPending {
|
||||
return decision, nil
|
||||
}
|
||||
}
|
||||
|
||||
return DecisionExpired, nil
|
||||
}
|
||||
|
||||
func (c *Client) pollDecision(ctx context.Context, base string, id OfferID) (Decision, error) {
|
||||
pollCtx, cancel := context.WithTimeout(ctx, c.pollTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(pollCtx, http.MethodGet, offerURL(base, id), nil)
|
||||
if err != nil {
|
||||
return DecisionPending, fmt.Errorf("build status request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return DecisionPending, fmt.Errorf("poll status: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp)
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return DecisionPending, ErrOfferNotFound
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return DecisionPending, statusError(resp)
|
||||
}
|
||||
|
||||
var offer OfferResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxOfferBodySize)).Decode(&offer); err != nil {
|
||||
return DecisionPending, fmt.Errorf("decode status response: %w", err)
|
||||
}
|
||||
if !offer.Decision.valid() {
|
||||
return DecisionPending, fmt.Errorf("%w: receiver returned decision %s", ErrInvalidOffer, offer.Decision)
|
||||
}
|
||||
return offer.Decision, nil
|
||||
}
|
||||
|
||||
func (c *Client) uploadFile(ctx context.Context, base string, id OfferID, index int, p Payload, progress ProgressFunc) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := range maxUploadAttempts {
|
||||
offset := int64(0)
|
||||
if attempt > 0 {
|
||||
if !sleepCtx(ctx, uploadRetryDelay) {
|
||||
return ctx.Err()
|
||||
}
|
||||
confirmed, err := c.confirmedOffset(ctx, base, id, index)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
offset = confirmed
|
||||
}
|
||||
|
||||
if offset >= p.Meta.Size {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.putFile(ctx, base, id, index, p, offset, progress); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
lastErr = err
|
||||
log.Debugf("upload attempt %d for %s: %v", attempt+1, p.Meta.Name, err)
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (c *Client) putFile(ctx context.Context, base string, id OfferID, index int, p Payload, offset int64, progress ProgressFunc) error {
|
||||
if p.Open == nil {
|
||||
return fmt.Errorf("payload %s has no reader", p.Meta.Name)
|
||||
}
|
||||
|
||||
body, err := p.Open(offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open payload: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := body.Close(); err != nil {
|
||||
log.Debugf("close payload reader: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var reader io.Reader = body
|
||||
if progress != nil {
|
||||
reader = &progressReader{
|
||||
r: body,
|
||||
sent: offset,
|
||||
total: p.Meta.Size,
|
||||
report: func(sent int64) {
|
||||
progress(index, sent, p.Meta.Size)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
url := fileURL(base, id, index) + "?offset=" + strconv.FormatInt(offset, 10)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build upload request: %w", err)
|
||||
}
|
||||
req.ContentLength = p.Meta.Size - offset
|
||||
req.Header.Set("Content-Type", contentTypeOrDefault(p.Meta.ContentType))
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send payload: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp)
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
return ErrNotAccepted
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
return statusError(resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) confirmedOffset(ctx context.Context, base string, id OfferID, index int) (int64, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, fileURL(base, id, index), nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("build probe request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("probe upload: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, statusError(resp)
|
||||
}
|
||||
|
||||
raw := resp.Header.Get(HeaderReceivedBytes)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
offset, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || offset < 0 {
|
||||
return 0, fmt.Errorf("invalid %s header %q", HeaderReceivedBytes, raw)
|
||||
}
|
||||
return offset, nil
|
||||
}
|
||||
|
||||
func baseURL(addr netip.AddrPort) string {
|
||||
return "http://" + net.JoinHostPort(addr.Addr().Unmap().String(), strconv.Itoa(int(addr.Port())))
|
||||
}
|
||||
|
||||
func offerURL(base string, id OfferID) string {
|
||||
return base + pathOffersSlash + string(id)
|
||||
}
|
||||
|
||||
func fileURL(base string, id OfferID, index int) string {
|
||||
return offerURL(base, id) + "/" + segmentFiles + "/" + strconv.Itoa(index)
|
||||
}
|
||||
|
||||
func contentTypeOrDefault(ct string) string {
|
||||
if ct == "" {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return ct
|
||||
}
|
||||
|
||||
func statusError(resp *http.Response) error {
|
||||
return fmt.Errorf("receiver returned %s", resp.Status)
|
||||
}
|
||||
|
||||
func drainAndClose(resp *http.Response) {
|
||||
if _, err := io.Copy(io.Discard, io.LimitReader(resp.Body, maxOfferBodySize)); err != nil {
|
||||
log.Tracef("drain response body: %v", err)
|
||||
}
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
log.Debugf("close response body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func decisionError(decision Decision) error {
|
||||
switch decision {
|
||||
case DecisionAccepted:
|
||||
return nil
|
||||
case DecisionDeclined:
|
||||
return ErrDeclined
|
||||
case DecisionExpired:
|
||||
return ErrExpired
|
||||
default:
|
||||
return fmt.Errorf("unexpected decision %s", decision)
|
||||
}
|
||||
}
|
||||
100
client/internal/filedrop/delivery.go
Normal file
100
client/internal/filedrop/delivery.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func deliver(spool *Spool, offer Offer, destDir string) ([]string, error) {
|
||||
if destDir == "" {
|
||||
return nil, fmt.Errorf("no destination directory configured")
|
||||
}
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create destination dir: %w", err)
|
||||
}
|
||||
|
||||
var delivered []string
|
||||
for i, f := range offer.Files {
|
||||
if f.Kind == KindText {
|
||||
continue
|
||||
}
|
||||
|
||||
dest, err := moveToUniqueName(spool.Path(offer.ID, i), destDir, sanitizeFileName(f.Name, i))
|
||||
if err != nil {
|
||||
return delivered, fmt.Errorf("deliver %s: %w", f.Name, err)
|
||||
}
|
||||
if err := chownToDirOwner(dest, destDir); err != nil {
|
||||
log.Debugf("failed to adopt owner for %s: %v", dest, err)
|
||||
}
|
||||
delivered = append(delivered, dest)
|
||||
}
|
||||
|
||||
spool.Remove(offer.ID)
|
||||
return delivered, nil
|
||||
}
|
||||
|
||||
func sanitizeFileName(name string, index int) string {
|
||||
name = filepath.Base(filepath.Clean(strings.ReplaceAll(name, "\\", "/")))
|
||||
if name == "" || name == "." || name == ".." || name == string(filepath.Separator) {
|
||||
return fmt.Sprintf("file-%d", index)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func moveToUniqueName(src, dir, name string) (string, error) {
|
||||
ext := filepath.Ext(name)
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
|
||||
for attempt := 0; attempt < 1000; attempt++ {
|
||||
candidate := name
|
||||
if attempt > 0 {
|
||||
candidate = fmt.Sprintf("%s (%d)%s", stem, attempt, ext)
|
||||
}
|
||||
dest := filepath.Join(dir, candidate)
|
||||
|
||||
f, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("create destination: %w", err)
|
||||
}
|
||||
|
||||
if err := moveInto(f, src); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(dest)
|
||||
return "", err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return "", fmt.Errorf("close destination: %w", err)
|
||||
}
|
||||
if err := os.Remove(src); err != nil {
|
||||
log.Debugf("failed to remove spooled source %s: %v", src, err)
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no free name for %s in %s", name, dir)
|
||||
}
|
||||
|
||||
func moveInto(dst *os.File, src string) error {
|
||||
s, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open spooled file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := s.Close(); err != nil {
|
||||
log.Debugf("close spooled file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(dst, s); err != nil {
|
||||
return fmt.Errorf("copy payload: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
7
client/internal/filedrop/delivery_other.go
Normal file
7
client/internal/filedrop/delivery_other.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build windows || js
|
||||
|
||||
package filedrop
|
||||
|
||||
func chownToDirOwner(string, string) error {
|
||||
return nil
|
||||
}
|
||||
29
client/internal/filedrop/delivery_unix.go
Normal file
29
client/internal/filedrop/delivery_unix.go
Normal file
@@ -0,0 +1,29 @@
|
||||
//go:build !windows && !js
|
||||
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func chownToDirOwner(path, dir string) error {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat destination dir: %w", err)
|
||||
}
|
||||
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if os.Geteuid() != 0 || int(stat.Uid) == os.Geteuid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.Chown(path, int(stat.Uid), int(stat.Gid)); err != nil {
|
||||
return fmt.Errorf("chown delivered file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
850
client/internal/filedrop/filedrop_test.go
Normal file
850
client/internal/filedrop/filedrop_test.go
Normal file
@@ -0,0 +1,850 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
const (
|
||||
testPeer = PeerKey("peer-pubkey")
|
||||
testProfile = profilemanager.ID("test-profile")
|
||||
)
|
||||
|
||||
type staticResolver struct {
|
||||
key PeerKey
|
||||
name string
|
||||
unknown bool
|
||||
}
|
||||
|
||||
func (r staticResolver) ResolvePeer(netip.Addr) (PeerKey, string, bool) {
|
||||
if r.unknown {
|
||||
return "", "", false
|
||||
}
|
||||
return r.key, r.name, true
|
||||
}
|
||||
|
||||
type recordingNotifier struct {
|
||||
mu sync.Mutex
|
||||
offers []Offer
|
||||
completed []Offer
|
||||
failed []Offer
|
||||
withdrawn []Offer
|
||||
progress int
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) OnOffer(o Offer) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.offers = append(n.offers, o)
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) OnProgress(Offer, int, int64) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.progress++
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) OnCompleted(o Offer) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.completed = append(n.completed, o)
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) OnFailed(o Offer, _ error) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.failed = append(n.failed, o)
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) OnWithdrawn(o Offer) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.withdrawn = append(n.withdrawn, o)
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) snapshot() (offers, completed, failed, withdrawn []Offer) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
return append([]Offer(nil), n.offers...), append([]Offer(nil), n.completed...),
|
||||
append([]Offer(nil), n.failed...), append([]Offer(nil), n.withdrawn...)
|
||||
}
|
||||
|
||||
func startTestServer(t *testing.T, mode Mode, resolver PeerResolver) (*Server, *Client, *recordingNotifier) {
|
||||
t.Helper()
|
||||
|
||||
policy := NewPolicyStore(testProfile)
|
||||
require.NoError(t, policy.Set(Policy{Mode: mode}))
|
||||
|
||||
notifier := &recordingNotifier{}
|
||||
srv, err := NewServer(ServerConfig{
|
||||
SpoolDir: t.TempDir(),
|
||||
Policy: policy,
|
||||
Resolver: resolver,
|
||||
Notifier: notifier,
|
||||
OfferTTL: 5 * time.Second,
|
||||
})
|
||||
require.NoError(t, err, "server setup must succeed")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
require.NoError(t, srv.Start(ctx, netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0)))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, srv.Stop())
|
||||
})
|
||||
|
||||
srv.mu.RLock()
|
||||
addr := srv.listener.Addr().String()
|
||||
srv.mu.RUnlock()
|
||||
|
||||
client, err := NewClient(ClientConfig{
|
||||
SenderName: "sender",
|
||||
PollTimeout: 2 * time.Second,
|
||||
OfferTimeout: 5 * time.Second,
|
||||
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "client setup must succeed")
|
||||
|
||||
return srv, client, notifier
|
||||
}
|
||||
|
||||
func filePayload(t *testing.T, name string, content []byte) Payload {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
require.NoError(t, os.WriteFile(path, content, 0o600))
|
||||
|
||||
return Payload{
|
||||
Meta: FileMeta{Name: name, Size: int64(len(content)), ContentType: "application/octet-stream"},
|
||||
Open: func(offset int64) (io.ReadCloser, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var testAddr = netip.AddrPortFrom(netip.AddrFrom4([4]byte{100, 64, 0, 1}), Port)
|
||||
|
||||
func TestAutoAcceptTransfersPayload(t *testing.T) {
|
||||
srv, client, notifier := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer, name: "laptop"})
|
||||
|
||||
content := []byte(strings.Repeat("netbird", 1000))
|
||||
payload := filePayload(t, "report.bin", content)
|
||||
|
||||
var lastSent int64
|
||||
id, err := client.Send(context.Background(), testAddr, []Payload{payload}, func(_ int, sent, _ int64) {
|
||||
lastSent = sent
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, id, "receiver must return an offer id")
|
||||
|
||||
assert.Equal(t, int64(len(content)), lastSent, "progress must reach the full payload size")
|
||||
|
||||
staged, err := os.ReadFile(srv.Spool().Path(id, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, staged, "staged bytes should match what was sent")
|
||||
|
||||
offer, ok := srv.Offers().Get(testPeer, id)
|
||||
require.True(t, ok, "offer must still be tracked")
|
||||
assert.Equal(t, StateCompleted, offer.State, "offer should be completed")
|
||||
assert.Equal(t, "laptop", offer.SenderName, "sender name should come from the resolver")
|
||||
|
||||
_, completed, _, _ := notifier.snapshot()
|
||||
require.Len(t, completed, 1, "one completion event expected")
|
||||
assert.Equal(t, id, completed[0].ID)
|
||||
}
|
||||
|
||||
func TestAskModeAcceptReleasesUpload(t *testing.T) {
|
||||
srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer})
|
||||
|
||||
content := []byte("consent required")
|
||||
payload := filePayload(t, "note.txt", content)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
offers, _, _, _ := notifier.snapshot()
|
||||
if len(offers) > 0 {
|
||||
srv.Offers().Decide(offers[0].ID, DecisionAccepted)
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
id, err := client.Send(context.Background(), testAddr, []Payload{payload}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
staged, err := os.ReadFile(srv.Spool().Path(id, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, staged, "payload should arrive after acceptance")
|
||||
|
||||
offers, _, _, _ := notifier.snapshot()
|
||||
require.Len(t, offers, 1, "the pending offer must be raised exactly once")
|
||||
assert.Equal(t, DecisionPending, offers[0].Decision, "the raised offer starts pending")
|
||||
}
|
||||
|
||||
func TestAskModeDeclineKeepsPayloadOut(t *testing.T) {
|
||||
srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer})
|
||||
|
||||
go func() {
|
||||
for {
|
||||
offers, _, _, _ := notifier.snapshot()
|
||||
if len(offers) > 0 {
|
||||
srv.Offers().Decide(offers[0].ID, DecisionDeclined)
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
id, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
|
||||
require.ErrorIs(t, err, ErrDeclined, "sender must see the decline")
|
||||
|
||||
_, statErr := os.Stat(srv.Spool().Path(id, 0))
|
||||
assert.True(t, os.IsNotExist(statErr), "declined payload must never be staged")
|
||||
}
|
||||
|
||||
func TestOffModeRefusesOffer(t *testing.T) {
|
||||
_, client, notifier := startTestServer(t, ModeOff, staticResolver{key: testPeer})
|
||||
|
||||
_, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
|
||||
require.ErrorIs(t, err, ErrRefused, "an off receiver must refuse the offer")
|
||||
|
||||
offers, _, _, _ := notifier.snapshot()
|
||||
assert.Empty(t, offers, "a refused offer must not reach the user")
|
||||
}
|
||||
|
||||
func TestUnknownSenderIsRefused(t *testing.T) {
|
||||
_, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{unknown: true})
|
||||
|
||||
_, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
|
||||
require.ErrorIs(t, err, ErrRefused, "an unresolvable source address must be refused")
|
||||
}
|
||||
|
||||
func TestUploadResumesFromConfirmedOffset(t *testing.T) {
|
||||
srv, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
|
||||
|
||||
content := []byte(strings.Repeat("resume", 500))
|
||||
payload := filePayload(t, "big.bin", content)
|
||||
|
||||
offer := srv.Offers().Add(testPeer, "", []FileMeta{payload.Meta}, DecisionAccepted)
|
||||
require.NoError(t, srv.Spool().Prepare(offer.ID))
|
||||
|
||||
half := int64(len(content) / 2)
|
||||
_, err := srv.Spool().Write(offer.ID, 0, 0, strings.NewReader(string(content[:half])), half)
|
||||
require.NoError(t, err)
|
||||
|
||||
srv.mu.RLock()
|
||||
base := "http://" + srv.listener.Addr().String()
|
||||
srv.mu.RUnlock()
|
||||
|
||||
confirmed, err := client.confirmedOffset(context.Background(), base, offer.ID, 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, half, confirmed, "receiver must report the staged prefix")
|
||||
|
||||
require.NoError(t, client.putFile(context.Background(), base, offer.ID, 0, payload, confirmed, nil))
|
||||
|
||||
staged, err := os.ReadFile(srv.Spool().Path(offer.ID, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, staged, "resumed upload must reconstruct the full payload")
|
||||
}
|
||||
|
||||
func TestUploadIsBoundedByAnnouncedSize(t *testing.T) {
|
||||
srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
|
||||
|
||||
// The request is issued raw: the stdlib client refuses to send a body that
|
||||
announced := int64(10)
|
||||
offer := srv.Offers().Add(testPeer, "", []FileMeta{{Name: "lie.bin", Size: announced}}, DecisionAccepted)
|
||||
require.NoError(t, srv.Spool().Prepare(offer.ID))
|
||||
|
||||
srv.mu.RLock()
|
||||
addr := srv.listener.Addr().String()
|
||||
srv.mu.RUnlock()
|
||||
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
oversized := strings.Repeat("A", 100)
|
||||
request := "PUT /v1/offers/" + string(offer.ID) + "/files/0?offset=0 HTTP/1.1\r\n" +
|
||||
"Host: filedrop\r\nContent-Length: 100\r\nConnection: close\r\n\r\n" + oversized
|
||||
_, err = conn.Write([]byte(request))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = io.ReadAll(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
staged, err := os.ReadFile(srv.Spool().Path(offer.ID, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, staged, int(announced), "staged size must be capped at the announced size")
|
||||
}
|
||||
|
||||
func TestCancelWithdrawsPendingOffer(t *testing.T) {
|
||||
srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer})
|
||||
|
||||
sendCtx, cancelSend := context.WithCancel(context.Background())
|
||||
defer cancelSend()
|
||||
|
||||
go func() {
|
||||
_, _ = client.Send(sendCtx, testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
|
||||
}()
|
||||
|
||||
var id OfferID
|
||||
require.Eventually(t, func() bool {
|
||||
offers, _, _, _ := notifier.snapshot()
|
||||
if len(offers) == 0 {
|
||||
return false
|
||||
}
|
||||
id = offers[0].ID
|
||||
return true
|
||||
}, 3*time.Second, 10*time.Millisecond, "offer must reach the receiver")
|
||||
|
||||
require.NoError(t, client.Cancel(context.Background(), testAddr, id))
|
||||
|
||||
_, ok := srv.Offers().Get(testPeer, id)
|
||||
assert.False(t, ok, "a withdrawn offer must be dropped")
|
||||
|
||||
_, _, _, withdrawn := notifier.snapshot()
|
||||
require.Len(t, withdrawn, 1, "the consent prompt must be withdrawn")
|
||||
assert.Equal(t, id, withdrawn[0].ID)
|
||||
}
|
||||
|
||||
func TestTextPayloadStaysInline(t *testing.T) {
|
||||
srv, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
|
||||
|
||||
id, err := client.Send(context.Background(), testAddr, []Payload{TextPayload("snippet", "hello peer")}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
offer, ok := srv.Offers().Get(testPeer, id)
|
||||
require.True(t, ok)
|
||||
require.Len(t, offer.Files, 1)
|
||||
assert.Equal(t, "hello peer", offer.Files[0].Text, "text must arrive in the offer itself")
|
||||
assert.Equal(t, StateCompleted, offer.State, "a text-only offer completes without an upload")
|
||||
|
||||
_, statErr := os.Stat(srv.Spool().Path(id, 0))
|
||||
assert.True(t, os.IsNotExist(statErr), "text payloads must not be written to the spool")
|
||||
}
|
||||
|
||||
func TestOfferExpiresWithoutDecision(t *testing.T) {
|
||||
policy := NewPolicyStore(testProfile)
|
||||
require.NoError(t, policy.SetMode(ModeAsk))
|
||||
|
||||
srv, err := NewServer(ServerConfig{
|
||||
SpoolDir: t.TempDir(),
|
||||
Policy: policy,
|
||||
Resolver: staticResolver{key: testPeer},
|
||||
OfferTTL: 100 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
offer := srv.Offers().Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionPending)
|
||||
|
||||
awaited, err := srv.Offers().Await(context.Background(), testPeer, offer.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, DecisionExpired, awaited.Decision, "an unanswered offer must expire")
|
||||
assert.Equal(t, StateExpired, awaited.State)
|
||||
}
|
||||
|
||||
func TestDecideIsFinal(t *testing.T) {
|
||||
store := NewOfferStore(time.Minute)
|
||||
offer := store.Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionPending)
|
||||
|
||||
_, ok := store.Decide(offer.ID, DecisionDeclined)
|
||||
require.True(t, ok, "the first decision must be recorded")
|
||||
|
||||
_, ok = store.Decide(offer.ID, DecisionAccepted)
|
||||
assert.False(t, ok, "a decided offer must not be revived")
|
||||
|
||||
current, ok := store.Get(testPeer, offer.ID)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, DecisionDeclined, current.Decision, "the original decision must stand")
|
||||
}
|
||||
|
||||
func TestOfferIsScopedToItsSender(t *testing.T) {
|
||||
store := NewOfferStore(time.Minute)
|
||||
offer := store.Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionAccepted)
|
||||
|
||||
_, ok := store.Get("other-peer", offer.ID)
|
||||
assert.False(t, ok, "another peer must not see the offer")
|
||||
|
||||
_, err := store.Await(context.Background(), "other-peer", offer.ID)
|
||||
assert.ErrorIs(t, err, ErrOfferNotFound, "another peer must not poll the offer")
|
||||
}
|
||||
|
||||
func TestPolicyEvaluation(t *testing.T) {
|
||||
store := NewPolicyStore(testProfile)
|
||||
require.NoError(t, store.Set(Policy{Mode: ModeAsk}))
|
||||
|
||||
assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "unknown senders are asked about")
|
||||
|
||||
require.NoError(t, store.SetSenderRule(testPeer, SenderRuleBlock))
|
||||
assert.Equal(t, ModeOff, store.Evaluate(testPeer), "a blocked sender is refused")
|
||||
|
||||
require.NoError(t, store.SetSenderRule(testPeer, SenderRuleAlwaysAccept))
|
||||
assert.Equal(t, ModeAutoAccept, store.Evaluate(testPeer), "an always-accept sender skips the prompt")
|
||||
|
||||
require.NoError(t, store.SetSenderRule(testPeer, SenderRuleDefault))
|
||||
assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "clearing the rule restores the base mode")
|
||||
}
|
||||
|
||||
func TestPolicyRejectsUnknownModeAndDeniesOnCorruptRule(t *testing.T) {
|
||||
store := NewPolicyStore(testProfile)
|
||||
|
||||
require.Error(t, store.SetMode(Mode(200)), "an unknown mode must be rejected")
|
||||
assert.Equal(t, ModeAsk, store.Get().Mode, "the rejected mode must not be applied")
|
||||
|
||||
require.NoError(t, store.SetSenderRule(testPeer, SenderRule(200)))
|
||||
assert.Equal(t, ModeOff, store.Evaluate(testPeer), "an unrecognized rule must deny")
|
||||
}
|
||||
|
||||
type memStore struct {
|
||||
mu sync.Mutex
|
||||
sections map[string][]byte
|
||||
loadErr error
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
return &memStore{sections: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func (s *memStore) Get(namespace string, v any) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.loadErr != nil {
|
||||
return false, s.loadErr
|
||||
}
|
||||
raw, ok := s.sections[namespace]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
return true, json.Unmarshal(raw, v)
|
||||
}
|
||||
|
||||
func (s *memStore) Put(namespace string, v any) error {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sections[namespace] = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPolicyIsScopedPerProfile(t *testing.T) {
|
||||
work, home := profilemanager.ID("work"), profilemanager.ID("home")
|
||||
workPrefs, homePrefs := newMemStore(), newMemStore()
|
||||
|
||||
workStore := LoadPolicyStore(work, workPrefs)
|
||||
require.NoError(t, workStore.SetMode(ModeOff))
|
||||
require.NoError(t, workStore.SetSenderRule(testPeer, SenderRuleBlock))
|
||||
|
||||
homeStore := LoadPolicyStore(home, homePrefs)
|
||||
assert.Equal(t, ModeAsk, homeStore.Get().Mode, "another profile keeps the default mode")
|
||||
assert.Equal(t, ModeAsk, homeStore.Evaluate(testPeer), "a block in one profile must not apply to another")
|
||||
|
||||
reloaded := LoadPolicyStore(work, workPrefs)
|
||||
assert.Equal(t, ModeOff, reloaded.Get().Mode, "the profile's mode must survive a reload")
|
||||
assert.Equal(t, ModeOff, reloaded.Evaluate(testPeer), "the profile's sender rule must survive a reload")
|
||||
}
|
||||
|
||||
func TestPolicyFallsBackToDefaultsOnLoadFailure(t *testing.T) {
|
||||
prefs := newMemStore()
|
||||
prefs.loadErr = errors.New("store unavailable")
|
||||
|
||||
store := LoadPolicyStore(testProfile, prefs)
|
||||
|
||||
assert.Equal(t, ModeAsk, store.Get().Mode, "an unreadable policy must not open the device up")
|
||||
assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "the safe default applies to unknown senders")
|
||||
}
|
||||
|
||||
func TestPolicyRejectsStoredInvalidModeOnLoad(t *testing.T) {
|
||||
prefs := newMemStore()
|
||||
require.NoError(t, prefs.Put(namespacePolicy, Policy{Mode: Mode(200)}))
|
||||
|
||||
store := LoadPolicyStore(testProfile, prefs)
|
||||
|
||||
assert.Equal(t, ModeAsk, store.Get().Mode, "a corrupted stored mode must fall back to the default")
|
||||
}
|
||||
|
||||
func TestStoreKeepsPolicyAndHistoryApart(t *testing.T) {
|
||||
prefs := newMemStore()
|
||||
|
||||
mgr, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir(), Store: prefs})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, mgr.Policy().SetMode(ModeAutoAccept))
|
||||
require.NoError(t, mgr.SetDestinationDir("/tmp/received"))
|
||||
mgr.history.Upsert(Transfer{ID: "offer-1", PeerKey: testPeer, State: StateCompleted})
|
||||
require.NoError(t, mgr.Close())
|
||||
|
||||
reloaded, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir(), Store: prefs})
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, reloaded.Close()) }()
|
||||
|
||||
assert.Equal(t, ModeAutoAccept, reloaded.Policy().Get().Mode, "the policy must survive a reload")
|
||||
assert.Equal(t, "/tmp/received", reloaded.DestinationDir(), "the destination must survive a reload")
|
||||
require.Len(t, reloaded.Transfers(), 1, "the history must survive a reload")
|
||||
assert.Equal(t, OfferID("offer-1"), reloaded.Transfers()[0].ID)
|
||||
}
|
||||
|
||||
func TestHistoryDropsOldestTerminalEntriesOverCap(t *testing.T) {
|
||||
history := LoadHistory(newMemStore())
|
||||
|
||||
for i := 0; i < historyCap+10; i++ {
|
||||
history.Upsert(Transfer{ID: OfferID(fmt.Sprintf("offer-%d", i)), State: StateCompleted})
|
||||
}
|
||||
|
||||
entries := history.List()
|
||||
require.Len(t, entries, historyCap, "the log must stay bounded")
|
||||
assert.Equal(t, OfferID(fmt.Sprintf("offer-%d", historyCap+9)), entries[0].ID, "the newest entry stays")
|
||||
}
|
||||
|
||||
func TestHistoryKeepsLiveTransfersOverCap(t *testing.T) {
|
||||
history := LoadHistory(newMemStore())
|
||||
|
||||
history.Upsert(Transfer{ID: "live", State: StateTransferring})
|
||||
for i := 0; i < historyCap+5; i++ {
|
||||
history.Upsert(Transfer{ID: OfferID(fmt.Sprintf("done-%d", i)), State: StateCompleted})
|
||||
}
|
||||
|
||||
_, ok := history.Get("live")
|
||||
assert.True(t, ok, "a transfer still running must not be pruned")
|
||||
}
|
||||
|
||||
func TestHistorySettlesTransfersInterruptedByRestart(t *testing.T) {
|
||||
store := newMemStore()
|
||||
|
||||
history := LoadHistory(store)
|
||||
history.Upsert(Transfer{ID: "pending", State: StatePending})
|
||||
history.Upsert(Transfer{ID: "moving", State: StateTransferring})
|
||||
history.Upsert(Transfer{ID: "done", State: StateCompleted})
|
||||
history.Upsert(Transfer{ID: "refused", State: StateDeclined})
|
||||
|
||||
// A fresh load stands in for the next process: nothing survives to finish
|
||||
// whatever was still moving.
|
||||
reloaded := LoadHistory(store)
|
||||
|
||||
for _, tc := range []struct {
|
||||
id OfferID
|
||||
state State
|
||||
reason FailureReason
|
||||
}{
|
||||
{"pending", StateFailed, ReasonInterrupted},
|
||||
{"moving", StateFailed, ReasonInterrupted},
|
||||
{"done", StateCompleted, ReasonNone},
|
||||
{"refused", StateDeclined, ReasonNone},
|
||||
} {
|
||||
entry, ok := reloaded.Get(tc.id)
|
||||
require.True(t, ok, "entry %s must survive the reload", tc.id)
|
||||
assert.Equal(t, tc.state, entry.State, "state of %s", tc.id)
|
||||
assert.Equal(t, tc.reason, entry.Reason, "reason of %s", tc.id)
|
||||
}
|
||||
|
||||
// The settled states are written back, so a third start sees them as final
|
||||
// rather than settling them again.
|
||||
third := LoadHistory(store)
|
||||
entry, ok := third.Get("moving")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StateFailed, entry.State)
|
||||
}
|
||||
|
||||
func TestSpoolWriteTruncatesStaleTail(t *testing.T) {
|
||||
spool, err := NewSpool(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
id := OfferID("offer")
|
||||
require.NoError(t, spool.Prepare(id))
|
||||
|
||||
_, err = spool.Write(id, 0, 0, strings.NewReader("AAAAAAAAAA"), 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
total, err := spool.Write(id, 0, 2, strings.NewReader("BB"), 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(4), total, "staged size follows the resumed write")
|
||||
|
||||
staged, err := os.ReadFile(spool.Path(id, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "AABB", string(staged), "stale bytes past the offset must be dropped")
|
||||
}
|
||||
|
||||
func TestSpoolCleanupDropsStalePartials(t *testing.T) {
|
||||
spool, err := NewSpool(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
stale, fresh := OfferID("stale"), OfferID("fresh")
|
||||
require.NoError(t, spool.Prepare(stale))
|
||||
require.NoError(t, spool.Prepare(fresh))
|
||||
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
require.NoError(t, os.Chtimes(spool.OfferDir(stale), old, old))
|
||||
|
||||
spool.Cleanup(time.Hour, time.Now())
|
||||
|
||||
_, err = os.Stat(spool.OfferDir(stale))
|
||||
assert.True(t, os.IsNotExist(err), "the stale offer dir must be removed")
|
||||
|
||||
_, err = os.Stat(spool.OfferDir(fresh))
|
||||
assert.NoError(t, err, "a recent offer dir must survive")
|
||||
}
|
||||
|
||||
func TestParseOfferPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
id OfferID
|
||||
index int
|
||||
hasIndex bool
|
||||
wantErr bool
|
||||
}{
|
||||
{path: "/v1/offers/abc", id: "abc"},
|
||||
{path: "/v1/offers/abc/files/3", id: "abc", index: 3, hasIndex: true},
|
||||
{path: "/v1/offers/", wantErr: true},
|
||||
{path: "/v1/offers/abc/files", wantErr: true},
|
||||
{path: "/v1/offers/abc/other/1", wantErr: true},
|
||||
{path: "/v1/offers/abc/files/-1", wantErr: true},
|
||||
{path: "/v1/offers/abc/files/x", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
id, index, hasIndex, err := parseOfferPath(tc.path)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.id, id)
|
||||
assert.Equal(t, tc.index, index)
|
||||
assert.Equal(t, tc.hasIndex, hasIndex)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOffer(t *testing.T) {
|
||||
assert.Error(t, validateOffer(nil), "an empty offer is invalid")
|
||||
assert.Error(t, validateOffer([]FileMeta{{Name: "x", Size: -1}}), "a negative size is invalid")
|
||||
assert.Error(t, validateOffer(make([]FileMeta, MaxOfferFiles+1)), "too many files is invalid")
|
||||
assert.Error(t, validateOffer([]FileMeta{{
|
||||
Name: "x", Kind: KindText, Text: strings.Repeat("a", MaxInlineTextSize+1),
|
||||
}}), "oversized inline text is invalid")
|
||||
|
||||
assert.NoError(t, validateOffer([]FileMeta{{Name: "x", Size: 10}}))
|
||||
}
|
||||
|
||||
func TestStopIsIdempotent(t *testing.T) {
|
||||
srv, err := NewServer(ServerConfig{
|
||||
SpoolDir: t.TempDir(),
|
||||
Policy: NewPolicyStore(testProfile),
|
||||
Resolver: staticResolver{key: testPeer},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, srv.Stop(), "stopping a server that never started is a no-op")
|
||||
|
||||
require.NoError(t, srv.Start(context.Background(), netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0)))
|
||||
require.NoError(t, srv.Stop())
|
||||
require.NoError(t, srv.Stop(), "the second stop must also be a no-op")
|
||||
}
|
||||
|
||||
func TestStartRejectsSecondStart(t *testing.T) {
|
||||
srv, err := NewServer(ServerConfig{
|
||||
SpoolDir: t.TempDir(),
|
||||
Policy: NewPolicyStore(testProfile),
|
||||
Resolver: staticResolver{key: testPeer},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0)
|
||||
require.NoError(t, srv.Start(context.Background(), addr))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, srv.Stop())
|
||||
})
|
||||
|
||||
err = srv.Start(context.Background(), addr)
|
||||
require.Error(t, err, "a running server must reject a second start")
|
||||
|
||||
srv.mu.RLock()
|
||||
running := srv.httpServer != nil && srv.listener != nil
|
||||
srv.mu.RUnlock()
|
||||
assert.True(t, running, "the original listener must survive the rejected start")
|
||||
}
|
||||
|
||||
func TestNewServerRequiresResolver(t *testing.T) {
|
||||
_, err := NewServer(ServerConfig{SpoolDir: t.TempDir(), Policy: NewPolicyStore(testProfile)})
|
||||
require.Error(t, err, "a server without peer resolution must not be constructed")
|
||||
}
|
||||
|
||||
func TestNewServerRequiresPolicy(t *testing.T) {
|
||||
_, err := NewServer(ServerConfig{SpoolDir: t.TempDir(), Resolver: staticResolver{key: testPeer}})
|
||||
require.Error(t, err, "a server without a profile policy must not be constructed")
|
||||
}
|
||||
|
||||
func TestNewClientRequiresDialer(t *testing.T) {
|
||||
_, err := NewClient(ClientConfig{})
|
||||
require.Error(t, err, "a client without a dialer must not be constructed")
|
||||
}
|
||||
|
||||
func TestSendRejectsEmptyPayloadSet(t *testing.T) {
|
||||
_, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
|
||||
|
||||
_, err := client.Send(context.Background(), testAddr, nil, nil)
|
||||
assert.True(t, errors.Is(err, ErrInvalidOffer), "sending nothing is an invalid offer")
|
||||
}
|
||||
|
||||
func TestServerFallsBackWhenPortBusy(t *testing.T) {
|
||||
blocker, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "blocker listener must bind")
|
||||
defer func() {
|
||||
require.NoError(t, blocker.Close())
|
||||
}()
|
||||
busyPort := uint16(blocker.Addr().(*net.TCPAddr).Port)
|
||||
|
||||
srv, err := NewServer(ServerConfig{
|
||||
SpoolDir: t.TempDir(),
|
||||
Policy: NewPolicyStore(testProfile),
|
||||
Resolver: staticResolver{key: testPeer},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), busyPort)
|
||||
require.NoError(t, srv.Start(context.Background(), addr), "start must fall back instead of failing")
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, srv.Stop())
|
||||
})
|
||||
|
||||
bound := srv.BoundPort()
|
||||
assert.NotZero(t, bound, "fallback must report the bound port")
|
||||
assert.NotEqual(t, busyPort, bound, "fallback must pick a different port")
|
||||
}
|
||||
|
||||
func TestPortRegistryAwait(t *testing.T) {
|
||||
reg := NewPortRegistry()
|
||||
|
||||
reg.Set(testPeer, 5000)
|
||||
port, changed := reg.Await(context.Background(), testPeer, 0)
|
||||
assert.True(t, changed, "known differing port must return immediately")
|
||||
assert.Equal(t, uint16(5000), port)
|
||||
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
reg.Set(testPeer, 5000)
|
||||
}()
|
||||
_, changed = reg.Await(context.Background(), testPeer, 5000)
|
||||
assert.False(t, changed, "an advertisement equal to the used port must release the waiter as unchanged")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, changed = reg.Await(ctx, testPeer, 5000)
|
||||
assert.False(t, changed, "timeout without advertisement must report unchanged")
|
||||
}
|
||||
|
||||
// senderManager builds a send-only manager whose dialer reaches the test server only
|
||||
// on realPort; other ports behave per defaultPortBehavior ("refuse" or "hang").
|
||||
func senderManager(t *testing.T, serverAddr string, realPort uint16, defaultPortBehavior string) *Manager {
|
||||
t.Helper()
|
||||
|
||||
mgr, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir()})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, mgr.Close())
|
||||
})
|
||||
|
||||
mgr.SetTunnel(func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
ap, err := netip.ParseAddrPort(addr)
|
||||
require.NoError(t, err, "dialer must receive a valid addr")
|
||||
if ap.Port() == realPort {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, serverAddr)
|
||||
}
|
||||
if defaultPortBehavior == "hang" {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, &net.OpError{Op: "dial", Net: network, Err: errors.New("connection refused")}
|
||||
}, "sender")
|
||||
|
||||
return mgr
|
||||
}
|
||||
|
||||
func waitForState(t *testing.T, mgr *Manager, id OfferID, want State) {
|
||||
t.Helper()
|
||||
require.Eventually(t, func() bool {
|
||||
tr, ok := mgr.history.Get(id)
|
||||
return ok && tr.State == want
|
||||
}, 10*time.Second, 20*time.Millisecond, "transfer must reach state %s", want)
|
||||
}
|
||||
|
||||
func TestSendRetriesOnAdvertisedPort(t *testing.T) {
|
||||
srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: PeerKey("sender-key")})
|
||||
srv.mu.RLock()
|
||||
serverAddr := srv.listener.Addr().String()
|
||||
srv.mu.RUnlock()
|
||||
realPort := srv.BoundPort()
|
||||
|
||||
mgr := senderManager(t, serverAddr, realPort, "refuse")
|
||||
|
||||
id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")})
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
mgr.Ports().Set(testPeer, realPort)
|
||||
|
||||
waitForState(t, mgr, id, StateCompleted)
|
||||
}
|
||||
|
||||
func TestSendAbortsHangingAttemptOnAdvertisedPort(t *testing.T) {
|
||||
srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: PeerKey("sender-key")})
|
||||
srv.mu.RLock()
|
||||
serverAddr := srv.listener.Addr().String()
|
||||
srv.mu.RUnlock()
|
||||
realPort := srv.BoundPort()
|
||||
|
||||
mgr := senderManager(t, serverAddr, realPort, "hang")
|
||||
|
||||
id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")})
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
mgr.Ports().Set(testPeer, realPort)
|
||||
|
||||
waitForState(t, mgr, id, StateCompleted)
|
||||
}
|
||||
|
||||
func TestSendFailsWhenSignalConfirmsUsedPort(t *testing.T) {
|
||||
mgr := senderManager(t, "127.0.0.1:1", 1, "refuse")
|
||||
|
||||
id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")})
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
mgr.Ports().Set(testPeer, 0)
|
||||
|
||||
waitForState(t, mgr, id, StateFailed)
|
||||
}
|
||||
205
client/internal/filedrop/history.go
Normal file
205
client/internal/filedrop/history.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// The transfer directions.
|
||||
const (
|
||||
DirectionReceived Direction = iota
|
||||
DirectionSent
|
||||
)
|
||||
|
||||
// The failure reasons a transfer can end with; None accompanies every other state.
|
||||
const (
|
||||
ReasonNone FailureReason = iota
|
||||
// ReasonUnreachable marks a transport-level failure: nothing listens on the
|
||||
// peer's file drop port, so the client is old or receiving is off.
|
||||
ReasonUnreachable
|
||||
// ReasonInterrupted marks a transfer that was still moving when the process
|
||||
// stopped; nothing survived to finish or resume it.
|
||||
ReasonInterrupted
|
||||
)
|
||||
|
||||
const historyCap = 30
|
||||
|
||||
// Direction tells whether a transfer was sent by this device or received on it.
|
||||
type Direction uint8
|
||||
|
||||
// FailureReason classifies why a transfer failed, when it is known.
|
||||
type FailureReason uint8
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (d Direction) String() string {
|
||||
switch d {
|
||||
case DirectionReceived:
|
||||
return "received"
|
||||
case DirectionSent:
|
||||
return "sent"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", uint8(d))
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer is one history entry: a sent or received offer with its outcome.
|
||||
type Transfer struct {
|
||||
ID OfferID `json:"id"`
|
||||
Direction Direction `json:"direction"`
|
||||
PeerKey PeerKey `json:"peerKey"`
|
||||
PeerName string `json:"peerName"`
|
||||
Files []FileMeta `json:"files"`
|
||||
State State `json:"state"`
|
||||
Transferred int64 `json:"transferred"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeliveredPaths []string `json:"deliveredPaths,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Reason FailureReason `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// History is the persisted transfer log of one profile, newest first.
|
||||
type History struct {
|
||||
mu sync.RWMutex
|
||||
store Store
|
||||
entries []Transfer
|
||||
}
|
||||
|
||||
// LoadHistory reads the stored transfer log, starting empty when unreadable.
|
||||
func LoadHistory(store Store) *History {
|
||||
h := &History{store: store}
|
||||
|
||||
var entries []Transfer
|
||||
if err := loadSection(store, namespaceHistory, &entries); err != nil {
|
||||
log.Warnf("failed to read file drop history, starting empty: %v", err)
|
||||
return h
|
||||
}
|
||||
h.entries = entries
|
||||
if h.settleInterrupted() {
|
||||
h.persist()
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (t Transfer) terminal() bool {
|
||||
switch t.State {
|
||||
case StateCompleted, StateDeclined, StateExpired, StateCancelled, StateFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (t Transfer) clone() Transfer {
|
||||
c := t
|
||||
c.Files = slices.Clone(t.Files)
|
||||
c.DeliveredPaths = slices.Clone(t.DeliveredPaths)
|
||||
return c
|
||||
}
|
||||
|
||||
// Upsert inserts or replaces the entry with the same ID and persists the log.
|
||||
func (h *History) Upsert(t Transfer) {
|
||||
t.UpdatedAt = time.Now()
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if i := h.indexOf(t.ID); i >= 0 {
|
||||
h.entries[i] = t
|
||||
} else {
|
||||
h.entries = slices.Insert(h.entries, 0, t)
|
||||
h.prune()
|
||||
}
|
||||
h.persist()
|
||||
}
|
||||
|
||||
// SetProgress updates the transferred byte count in memory only.
|
||||
func (h *History) SetProgress(id OfferID, transferred int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if i := h.indexOf(id); i >= 0 {
|
||||
h.entries[i].Transferred = transferred
|
||||
h.entries[i].UpdatedAt = time.Now()
|
||||
if h.entries[i].State == StatePending {
|
||||
h.entries[i].State = StateTransferring
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the entry with the given ID.
|
||||
func (h *History) Get(id OfferID) (Transfer, bool) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
if i := h.indexOf(id); i >= 0 {
|
||||
return h.entries[i].clone(), true
|
||||
}
|
||||
return Transfer{}, false
|
||||
}
|
||||
|
||||
// List returns every entry, newest first.
|
||||
func (h *History) List() []Transfer {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
list := make([]Transfer, len(h.entries))
|
||||
for i, e := range h.entries {
|
||||
list[i] = e.clone()
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Delete removes one entry and persists the log.
|
||||
func (h *History) Delete(id OfferID) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if i := h.indexOf(id); i >= 0 {
|
||||
h.entries = slices.Delete(h.entries, i, i+1)
|
||||
h.persist()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *History) indexOf(id OfferID) int {
|
||||
return slices.IndexFunc(h.entries, func(t Transfer) bool { return t.ID == id })
|
||||
}
|
||||
|
||||
func (h *History) prune() {
|
||||
if len(h.entries) <= historyCap {
|
||||
return
|
||||
}
|
||||
for i := len(h.entries) - 1; i >= 0 && len(h.entries) > historyCap; i-- {
|
||||
if h.entries[i].terminal() {
|
||||
h.entries = slices.Delete(h.entries, i, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// settleInterrupted closes out transfers that were still moving when the
|
||||
// process died. Nothing is left to finish them, so left alone they would sit in
|
||||
// the log as permanently pending. Reports whether anything changed.
|
||||
func (h *History) settleInterrupted() bool {
|
||||
changed := false
|
||||
for i, t := range h.entries {
|
||||
if t.terminal() {
|
||||
continue
|
||||
}
|
||||
h.entries[i].State = StateFailed
|
||||
h.entries[i].Reason = ReasonInterrupted
|
||||
h.entries[i].UpdatedAt = time.Now()
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (h *History) persist() {
|
||||
if err := saveSection(h.store, namespaceHistory, h.entries); err != nil {
|
||||
log.Warnf("failed to write file drop history: %v", err)
|
||||
}
|
||||
}
|
||||
221
client/internal/filedrop/http.go
Normal file
221
client/internal/filedrop/http.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// httpTransport adapts the receiver to plain HTTP/1.1 over the tunnel. It only
|
||||
// parses requests, maps domain errors to status codes, and encodes responses.
|
||||
type httpTransport struct {
|
||||
recv *receiver
|
||||
}
|
||||
|
||||
func (t *httpTransport) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(pathOffers, t.handleOffers)
|
||||
mux.HandleFunc(pathOffersSlash, t.handleOffer)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (t *httpTransport) handleOffers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
sender, ok := t.identify(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req OfferRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxOfferBodySize)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed offer")
|
||||
return
|
||||
}
|
||||
|
||||
offer, err := t.recv.submitOffer(sender, req)
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
status := http.StatusAccepted
|
||||
if offer.Decision == DecisionAccepted {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
writeJSON(w, status, OfferResponse{ID: offer.ID, Decision: offer.Decision})
|
||||
}
|
||||
|
||||
func (t *httpTransport) handleOffer(w http.ResponseWriter, r *http.Request) {
|
||||
sender, ok := t.identify(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id, index, hasIndex, err := parseOfferPath(r.URL.Path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "unknown path")
|
||||
return
|
||||
}
|
||||
|
||||
if !hasIndex {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
t.handleOfferStatus(w, r, sender, id)
|
||||
case http.MethodDelete:
|
||||
t.handleOfferCancel(w, sender, id)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
t.handleUpload(w, r, sender, id, index)
|
||||
case http.MethodHead:
|
||||
t.handleUploadProbe(w, sender, id, index)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (t *httpTransport) handleOfferStatus(w http.ResponseWriter, r *http.Request, sender senderIdentity, id OfferID) {
|
||||
offer, err := t.recv.awaitDecision(r.Context(), sender, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrOfferNotFound) {
|
||||
writeDomainError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, OfferResponse{ID: offer.ID, Decision: offer.Decision})
|
||||
}
|
||||
|
||||
func (t *httpTransport) handleOfferCancel(w http.ResponseWriter, sender senderIdentity, id OfferID) {
|
||||
if err := t.recv.withdraw(sender, id); err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (t *httpTransport) handleUploadProbe(w http.ResponseWriter, sender senderIdentity, id OfferID, index int) {
|
||||
received, err := t.recv.receivedBytes(sender, id, index)
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set(HeaderReceivedBytes, strconv.FormatInt(received, 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (t *httpTransport) handleUpload(w http.ResponseWriter, r *http.Request, sender senderIdentity, id OfferID, index int) {
|
||||
offset, err := parseOffset(r.URL.Query().Get("offset"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := t.recv.upload(sender, id, index, offset, r.Body); err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (t *httpTransport) identify(w http.ResponseWriter, r *http.Request) (senderIdentity, bool) {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, "unknown sender")
|
||||
return senderIdentity{}, false
|
||||
}
|
||||
|
||||
sender, ok := t.recv.identify(addr)
|
||||
if !ok {
|
||||
writeError(w, http.StatusForbidden, "unknown sender")
|
||||
return senderIdentity{}, false
|
||||
}
|
||||
return sender, true
|
||||
}
|
||||
|
||||
func writeDomainError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrRefused), errors.Is(err, ErrNotAccepted), errors.Is(err, ErrUnknownPeer):
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
case errors.Is(err, ErrOfferNotFound):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, ErrInvalidOffer):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, ErrStorage):
|
||||
writeError(w, http.StatusInsufficientStorage, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func parseOfferPath(path string) (OfferID, int, bool, error) {
|
||||
rest := strings.TrimPrefix(path, pathOffersSlash)
|
||||
if rest == "" || rest == path {
|
||||
return "", 0, false, fmt.Errorf("not an offer path")
|
||||
}
|
||||
|
||||
parts := strings.Split(rest, "/")
|
||||
if parts[0] == "" {
|
||||
return "", 0, false, fmt.Errorf("missing offer id")
|
||||
}
|
||||
id := OfferID(parts[0])
|
||||
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
return id, 0, false, nil
|
||||
case 3:
|
||||
if parts[1] != segmentFiles {
|
||||
return "", 0, false, fmt.Errorf("unknown sub-resource %q", parts[1])
|
||||
}
|
||||
index, err := strconv.Atoi(parts[2])
|
||||
if err != nil || index < 0 {
|
||||
return "", 0, false, fmt.Errorf("invalid file index")
|
||||
}
|
||||
return id, index, true, nil
|
||||
default:
|
||||
return "", 0, false, fmt.Errorf("unknown offer path")
|
||||
}
|
||||
}
|
||||
|
||||
func parseOffset(raw string) (int64, error) {
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
offset, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || offset < 0 {
|
||||
return 0, fmt.Errorf("invalid offset")
|
||||
}
|
||||
return offset, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
log.Debugf("write file drop response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
660
client/internal/filedrop/manager.go
Normal file
660
client/internal/filedrop/manager.go
Normal file
@@ -0,0 +1,660 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
// The event kinds. Progress is not an event: live transfers are polled.
|
||||
const (
|
||||
EventOffer EventKind = iota
|
||||
EventCompleted
|
||||
EventFailed
|
||||
EventWithdrawn
|
||||
)
|
||||
|
||||
// portSignalGrace bounds how long a failed attempt waits for one signal message
|
||||
// that may advertise the receiver's actual port before giving up.
|
||||
const portSignalGrace = 3 * time.Second
|
||||
|
||||
// ErrNotConnected indicates the operation needs a running tunnel.
|
||||
var ErrNotConnected = errors.New("not connected")
|
||||
|
||||
// EventKind classifies the events the manager surfaces to the platform layer.
|
||||
type EventKind uint8
|
||||
|
||||
// EventSink receives transfer events. Calls may come from server goroutines.
|
||||
type EventSink func(kind EventKind, transfer Transfer)
|
||||
|
||||
// ManagerConfig configures a per-profile file drop manager. Policy and history
|
||||
// live in Store; DataDir only holds the spool of partially received files,
|
||||
// which is disposable and never outlives an offer's TTL.
|
||||
type ManagerConfig struct {
|
||||
Profile profilemanager.ID
|
||||
DataDir string
|
||||
Store Store
|
||||
Events EventSink
|
||||
OfferTTL time.Duration
|
||||
}
|
||||
|
||||
type sendHandle struct {
|
||||
cancel context.CancelFunc
|
||||
ip netip.Addr
|
||||
addr netip.AddrPort
|
||||
remoteID OfferID
|
||||
}
|
||||
|
||||
// Manager owns one profile's file drop state.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
profile profilemanager.ID
|
||||
dataDir string
|
||||
store Store
|
||||
policy *PolicyStore
|
||||
history *History
|
||||
events EventSink
|
||||
offerTTL time.Duration
|
||||
|
||||
server *Server
|
||||
ports *PortRegistry
|
||||
dial DialFunc
|
||||
senderName string
|
||||
sends map[OfferID]*sendHandle
|
||||
sendWg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewManager loads or initializes the file drop state for one profile.
|
||||
func NewManager(cfg ManagerConfig) (*Manager, error) {
|
||||
if cfg.DataDir == "" {
|
||||
return nil, errors.New("data dir is required")
|
||||
}
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create file drop dir: %w", err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
profile: cfg.Profile,
|
||||
dataDir: cfg.DataDir,
|
||||
store: cfg.Store,
|
||||
policy: LoadPolicyStore(cfg.Profile, cfg.Store),
|
||||
history: LoadHistory(cfg.Store),
|
||||
events: cfg.Events,
|
||||
offerTTL: cfg.OfferTTL,
|
||||
ports: NewPortRegistry(),
|
||||
sends: make(map[OfferID]*sendHandle),
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Profile returns the profile this manager belongs to.
|
||||
func (m *Manager) Profile() profilemanager.ID {
|
||||
return m.profile
|
||||
}
|
||||
|
||||
// Policy returns the receiving policy store.
|
||||
func (m *Manager) Policy() *PolicyStore {
|
||||
return m.policy
|
||||
}
|
||||
|
||||
// Ports returns the registry of peer-advertised listen ports; the engine feeds it
|
||||
// from incoming signal messages.
|
||||
func (m *Manager) Ports() *PortRegistry {
|
||||
return m.ports
|
||||
}
|
||||
|
||||
// ReceiverPort returns the port the receiver is actually bound to, 0 when stopped.
|
||||
func (m *Manager) ReceiverPort() uint16 {
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.mu.Unlock()
|
||||
if server == nil {
|
||||
return 0
|
||||
}
|
||||
return server.BoundPort()
|
||||
}
|
||||
|
||||
// Transfers returns the history entries, newest first, with pending offers included.
|
||||
func (m *Manager) Transfers() []Transfer {
|
||||
return m.history.List()
|
||||
}
|
||||
|
||||
// DeleteTransfer removes a history entry. A live transfer is cancelled first.
|
||||
func (m *Manager) DeleteTransfer(id OfferID) {
|
||||
if t, ok := m.history.Get(id); ok && !t.terminal() {
|
||||
m.Cancel(id)
|
||||
}
|
||||
m.history.Delete(id)
|
||||
}
|
||||
|
||||
// DestinationDir returns the directory received files are delivered to.
|
||||
func (m *Manager) DestinationDir() string {
|
||||
return m.policy.DestinationDir()
|
||||
}
|
||||
|
||||
// SetDestinationDir persists the delivery directory.
|
||||
func (m *Manager) SetDestinationDir(dir string) error {
|
||||
return m.policy.SetDestinationDir(dir)
|
||||
}
|
||||
|
||||
// StartReceiver binds the receiving server on addr.
|
||||
func (m *Manager) StartReceiver(ctx context.Context, addr netip.AddrPort, netstackNet *netstack.Net, resolver PeerResolver) error {
|
||||
m.mu.Lock()
|
||||
if m.server != nil {
|
||||
m.mu.Unlock()
|
||||
return errors.New("receiver is already running")
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
server, err := NewServer(ServerConfig{
|
||||
SpoolDir: filepath.Join(m.dataDir, "spool"),
|
||||
Policy: m.policy,
|
||||
Resolver: resolver,
|
||||
Notifier: m,
|
||||
OfferTTL: m.offerTTL,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create receiver: %w", err)
|
||||
}
|
||||
if netstackNet != nil {
|
||||
server.SetNetstackNet(netstackNet)
|
||||
}
|
||||
|
||||
if err := server.Start(ctx, addr); err != nil {
|
||||
return fmt.Errorf("start receiver: %w", err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.server = server
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddReceiverListener serves the receiver on an additional address, such as IPv6.
|
||||
func (m *Manager) AddReceiverListener(ctx context.Context, addr netip.AddrPort) error {
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.mu.Unlock()
|
||||
|
||||
if server == nil {
|
||||
return errors.New("receiver is not running")
|
||||
}
|
||||
return server.AddListener(ctx, addr)
|
||||
}
|
||||
|
||||
// StopReceiver shuts the receiving server down and drops the tunnel dialer.
|
||||
func (m *Manager) StopReceiver() error {
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.server = nil
|
||||
m.dial = nil
|
||||
m.mu.Unlock()
|
||||
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
return server.Stop()
|
||||
}
|
||||
|
||||
// SetTunnel gives the manager the tunnel dialer and the local sender name.
|
||||
func (m *Manager) SetTunnel(dial DialFunc, senderName string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.dial = dial
|
||||
m.senderName = senderName
|
||||
}
|
||||
|
||||
// Close stops the receiver and aborts every outgoing transfer.
|
||||
func (m *Manager) Close() error {
|
||||
err := m.StopReceiver()
|
||||
|
||||
m.mu.Lock()
|
||||
for _, h := range m.sends {
|
||||
h.cancel()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
m.sendWg.Wait()
|
||||
return err
|
||||
}
|
||||
|
||||
// Send starts an asynchronous transfer and returns its local transfer ID.
|
||||
func (m *Manager) Send(peer PeerKey, peerName string, addr netip.Addr, payloads []Payload) (OfferID, error) {
|
||||
if len(payloads) == 0 {
|
||||
return "", fmt.Errorf("%w: no payloads", ErrInvalidOffer)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
dial, senderName := m.dial, m.senderName
|
||||
m.mu.Unlock()
|
||||
if dial == nil {
|
||||
return "", ErrNotConnected
|
||||
}
|
||||
|
||||
client, err := NewClient(ClientConfig{Dial: dial, SenderName: senderName, OfferTimeout: m.offerTTL})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
id := OfferID(uuid.NewString())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
handle := &sendHandle{cancel: cancel, ip: addr}
|
||||
|
||||
m.mu.Lock()
|
||||
m.sends[id] = handle
|
||||
m.mu.Unlock()
|
||||
|
||||
transfer := Transfer{
|
||||
ID: id,
|
||||
Direction: DirectionSent,
|
||||
PeerKey: peer,
|
||||
PeerName: peerName,
|
||||
Files: payloadMetas(payloads),
|
||||
State: StatePending,
|
||||
TotalSize: payloadTotal(payloads),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
m.history.Upsert(transfer)
|
||||
|
||||
m.sendWg.Add(1)
|
||||
go func() {
|
||||
defer m.sendWg.Done()
|
||||
defer cancel()
|
||||
m.runSend(ctx, client, handle, transfer, payloads)
|
||||
|
||||
m.mu.Lock()
|
||||
delete(m.sends, id)
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Cancel aborts a transfer in either direction.
|
||||
func (m *Manager) Cancel(id OfferID) {
|
||||
m.mu.Lock()
|
||||
handle := m.sends[id]
|
||||
server := m.server
|
||||
var remoteAddr netip.AddrPort
|
||||
var remoteID OfferID
|
||||
if handle != nil {
|
||||
remoteAddr, remoteID = handle.addr, handle.remoteID
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if handle != nil {
|
||||
handle.cancel()
|
||||
if remoteID != "" && remoteAddr.IsValid() {
|
||||
m.withdrawRemote(remoteAddr, remoteID)
|
||||
}
|
||||
m.finishTransfer(id, StateCancelled, "")
|
||||
return
|
||||
}
|
||||
|
||||
if server != nil {
|
||||
if offer, ok := server.Offers().Decide(id, DecisionDeclined); ok {
|
||||
server.Spool().Remove(offer.ID)
|
||||
}
|
||||
}
|
||||
m.finishTransfer(id, StateCancelled, "")
|
||||
}
|
||||
|
||||
// Accept releases a pending incoming offer for upload.
|
||||
func (m *Manager) Accept(id OfferID) error {
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.mu.Unlock()
|
||||
if server == nil {
|
||||
return ErrNotConnected
|
||||
}
|
||||
|
||||
offer, ok := server.Offers().Decide(id, DecisionAccepted)
|
||||
if !ok {
|
||||
return ErrOfferNotFound
|
||||
}
|
||||
|
||||
if offer.State == StateCompleted {
|
||||
m.OnCompleted(offer)
|
||||
return nil
|
||||
}
|
||||
|
||||
m.history.SetProgress(id, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decline refuses a pending incoming offer.
|
||||
func (m *Manager) Decline(id OfferID) error {
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.mu.Unlock()
|
||||
if server == nil {
|
||||
return ErrNotConnected
|
||||
}
|
||||
|
||||
offer, ok := server.Offers().Decide(id, DecisionDeclined)
|
||||
if !ok {
|
||||
return ErrOfferNotFound
|
||||
}
|
||||
|
||||
server.Spool().Remove(offer.ID)
|
||||
m.finishTransfer(id, StateDeclined, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSenderRule records a per-sender exception.
|
||||
func (m *Manager) SetSenderRule(peer PeerKey, rule SenderRule) error {
|
||||
if err := m.policy.SetSenderRule(peer, rule); err != nil {
|
||||
return err
|
||||
}
|
||||
if rule != SenderRuleBlock {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.mu.Unlock()
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, offer := range server.Offers().List() {
|
||||
if offer.Sender != peer || offer.Decision != DecisionPending {
|
||||
continue
|
||||
}
|
||||
if declined, ok := server.Offers().Decide(offer.ID, DecisionDeclined); ok {
|
||||
server.Spool().Remove(declined.ID)
|
||||
m.finishTransfer(declined.ID, StateDeclined, "")
|
||||
m.emit(EventWithdrawn, m.transferOf(declined.ID))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) runSend(ctx context.Context, client *Client, handle *sendHandle, transfer Transfer, payloads []Payload) {
|
||||
addr, remoteID, decision, err := m.offerWithPortRetry(ctx, client, handle, transfer.PeerKey, payloads)
|
||||
if err != nil {
|
||||
m.failSend(ctx, transfer.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
decision, err = client.AwaitDecision(ctx, addr, remoteID, decision)
|
||||
if err != nil {
|
||||
m.failSend(ctx, transfer.ID, err)
|
||||
return
|
||||
}
|
||||
if err := decisionError(decision); err != nil {
|
||||
m.failSend(ctx, transfer.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
m.history.SetProgress(transfer.ID, 0)
|
||||
|
||||
completed := make([]int64, len(payloads))
|
||||
progress := func(index int, sent, _ int64) {
|
||||
completed[index] = sent
|
||||
var total int64
|
||||
for _, n := range completed {
|
||||
total += n
|
||||
}
|
||||
m.history.SetProgress(transfer.ID, total)
|
||||
}
|
||||
|
||||
if err := client.Upload(ctx, addr, remoteID, payloads, progress); err != nil {
|
||||
m.failSend(ctx, transfer.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
m.history.SetProgress(transfer.ID, transfer.TotalSize)
|
||||
m.finishTransfer(transfer.ID, StateCompleted, "")
|
||||
m.emit(EventCompleted, m.transferOf(transfer.ID))
|
||||
}
|
||||
|
||||
// offerWithPortRetry places the offer on the last advertised port, falling back to
|
||||
// the default. When the attempt fails on the transport, it waits out one signal
|
||||
// message that may carry the receiver's actual port and retries there once. A port
|
||||
// learned mid-attempt aborts the attempt immediately instead of letting it hang.
|
||||
func (m *Manager) offerWithPortRetry(ctx context.Context, client *Client, handle *sendHandle, key PeerKey, payloads []Payload) (netip.AddrPort, OfferID, Decision, error) {
|
||||
used := m.ports.Port(key)
|
||||
addr := netip.AddrPortFrom(handle.ip, effectivePort(used))
|
||||
|
||||
remoteID, decision, err := m.offerWatchingPorts(ctx, client, key, used, addr, payloads)
|
||||
if err == nil {
|
||||
m.storeRemote(handle, addr, remoteID)
|
||||
return addr, remoteID, decision, nil
|
||||
}
|
||||
if ctx.Err() != nil || !transportFailure(err) {
|
||||
return addr, remoteID, decision, err
|
||||
}
|
||||
|
||||
graceCtx, cancel := context.WithTimeout(ctx, portSignalGrace)
|
||||
port, changed := m.ports.Await(graceCtx, key, used)
|
||||
cancel()
|
||||
if !changed {
|
||||
return addr, remoteID, decision, err
|
||||
}
|
||||
|
||||
addr = netip.AddrPortFrom(handle.ip, effectivePort(port))
|
||||
remoteID, decision, err = client.Offer(ctx, addr, payloads)
|
||||
if err != nil {
|
||||
return addr, remoteID, decision, err
|
||||
}
|
||||
m.storeRemote(handle, addr, remoteID)
|
||||
return addr, remoteID, decision, nil
|
||||
}
|
||||
|
||||
// offerWatchingPorts runs the offer while watching for a port advertisement that
|
||||
// differs from the one in use; such an advertisement aborts the in-flight attempt.
|
||||
func (m *Manager) offerWatchingPorts(ctx context.Context, client *Client, key PeerKey, used uint16, addr netip.AddrPort, payloads []Payload) (OfferID, Decision, error) {
|
||||
watchCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
if _, changed := m.ports.Await(watchCtx, key, used); changed {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
return client.Offer(watchCtx, addr, payloads)
|
||||
}
|
||||
|
||||
func (m *Manager) storeRemote(handle *sendHandle, addr netip.AddrPort, remoteID OfferID) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
handle.addr = addr
|
||||
handle.remoteID = remoteID
|
||||
}
|
||||
|
||||
func (m *Manager) failSend(ctx context.Context, id OfferID, err error) {
|
||||
if ctx.Err() != nil {
|
||||
m.finishTransfer(id, StateCancelled, "")
|
||||
return
|
||||
}
|
||||
|
||||
state := StateFailed
|
||||
switch {
|
||||
case errors.Is(err, ErrDeclined):
|
||||
state = StateDeclined
|
||||
case errors.Is(err, ErrExpired):
|
||||
state = StateExpired
|
||||
}
|
||||
|
||||
message := ""
|
||||
reason := ReasonNone
|
||||
if state == StateFailed {
|
||||
message = err.Error()
|
||||
if transportFailure(err) {
|
||||
reason = ReasonUnreachable
|
||||
}
|
||||
}
|
||||
m.finishTransferReason(id, state, message, reason)
|
||||
m.emit(EventFailed, m.transferOf(id))
|
||||
}
|
||||
|
||||
func (m *Manager) withdrawRemote(addr netip.AddrPort, remoteID OfferID) {
|
||||
m.mu.Lock()
|
||||
dial, senderName := m.dial, m.senderName
|
||||
m.mu.Unlock()
|
||||
if dial == nil {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := NewClient(ClientConfig{Dial: dial, SenderName: senderName})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Cancel(ctx, addr, remoteID); err != nil {
|
||||
log.Debugf("failed to withdraw file drop offer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// OnOffer implements Notifier for the receiver server.
|
||||
func (m *Manager) OnOffer(offer Offer) {
|
||||
transfer := Transfer{
|
||||
ID: offer.ID,
|
||||
Direction: DirectionReceived,
|
||||
PeerKey: offer.Sender,
|
||||
PeerName: offer.SenderName,
|
||||
Files: offer.Files,
|
||||
State: offer.State,
|
||||
TotalSize: offer.TotalSize(),
|
||||
CreatedAt: offer.CreatedAt,
|
||||
}
|
||||
m.history.Upsert(transfer)
|
||||
|
||||
if offer.Decision == DecisionPending {
|
||||
m.emit(EventOffer, transfer)
|
||||
}
|
||||
if offer.Decision == DecisionAccepted && offer.State == StateCompleted {
|
||||
m.OnCompleted(offer)
|
||||
}
|
||||
}
|
||||
|
||||
// OnProgress implements Notifier.
|
||||
func (m *Manager) OnProgress(offer Offer, index int, received int64) {
|
||||
var total int64
|
||||
for i, n := range offer.Progress {
|
||||
if i == index {
|
||||
n = received
|
||||
}
|
||||
total += n
|
||||
}
|
||||
m.history.SetProgress(offer.ID, total)
|
||||
}
|
||||
|
||||
// OnCompleted implements Notifier.
|
||||
func (m *Manager) OnCompleted(offer Offer) {
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.mu.Unlock()
|
||||
if server == nil {
|
||||
return
|
||||
}
|
||||
|
||||
transfer, ok := m.history.Get(offer.ID)
|
||||
if !ok || transfer.State == StateCompleted {
|
||||
return
|
||||
}
|
||||
|
||||
delivered, err := deliver(server.Spool(), offer, m.policy.DestinationDir())
|
||||
if err != nil {
|
||||
log.Errorf("failed to deliver file drop payloads: %v", err)
|
||||
m.finishTransfer(offer.ID, StateFailed, err.Error())
|
||||
m.emit(EventFailed, m.transferOf(offer.ID))
|
||||
return
|
||||
}
|
||||
|
||||
transfer.State = StateCompleted
|
||||
transfer.Transferred = transfer.TotalSize
|
||||
transfer.DeliveredPaths = delivered
|
||||
transfer.Error = ""
|
||||
m.history.Upsert(transfer)
|
||||
m.emit(EventCompleted, transfer)
|
||||
}
|
||||
|
||||
// OnFailed implements Notifier.
|
||||
func (m *Manager) OnFailed(offer Offer, err error) {
|
||||
if errors.Is(err, ErrExpired) {
|
||||
m.finishTransfer(offer.ID, StateExpired, "")
|
||||
m.emit(EventWithdrawn, m.transferOf(offer.ID))
|
||||
return
|
||||
}
|
||||
m.finishTransfer(offer.ID, StateFailed, err.Error())
|
||||
m.emit(EventFailed, m.transferOf(offer.ID))
|
||||
}
|
||||
|
||||
// OnWithdrawn implements Notifier: the sender cancelled, so the consent prompt goes away.
|
||||
func (m *Manager) OnWithdrawn(offer Offer) {
|
||||
m.finishTransfer(offer.ID, StateCancelled, "")
|
||||
m.emit(EventWithdrawn, m.transferOf(offer.ID))
|
||||
}
|
||||
|
||||
func (m *Manager) finishTransfer(id OfferID, state State, message string) {
|
||||
m.finishTransferReason(id, state, message, ReasonNone)
|
||||
}
|
||||
|
||||
func (m *Manager) finishTransferReason(id OfferID, state State, message string, reason FailureReason) {
|
||||
transfer, ok := m.history.Get(id)
|
||||
if !ok || transfer.terminal() {
|
||||
return
|
||||
}
|
||||
transfer.State = state
|
||||
transfer.Error = message
|
||||
transfer.Reason = reason
|
||||
m.history.Upsert(transfer)
|
||||
}
|
||||
|
||||
func (m *Manager) transferOf(id OfferID) Transfer {
|
||||
t, _ := m.history.Get(id)
|
||||
return t
|
||||
}
|
||||
|
||||
func (m *Manager) emit(kind EventKind, transfer Transfer) {
|
||||
if m.events != nil && transfer.ID != "" {
|
||||
m.events(kind, transfer)
|
||||
}
|
||||
}
|
||||
|
||||
func payloadMetas(payloads []Payload) []FileMeta {
|
||||
metas := make([]FileMeta, len(payloads))
|
||||
for i, p := range payloads {
|
||||
metas[i] = p.Meta
|
||||
}
|
||||
return metas
|
||||
}
|
||||
|
||||
func payloadTotal(payloads []Payload) int64 {
|
||||
var total int64
|
||||
for _, p := range payloads {
|
||||
total += p.Meta.Size
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func effectivePort(advertised uint16) uint16 {
|
||||
if advertised == 0 {
|
||||
return Port
|
||||
}
|
||||
return advertised
|
||||
}
|
||||
|
||||
// transportFailure reports whether the offer never reached the receiver; any HTTP
|
||||
// response, refusal included, proves the port right and is not retried elsewhere.
|
||||
func transportFailure(err error) bool {
|
||||
var urlErr *url.Error
|
||||
return errors.As(err, &urlErr)
|
||||
}
|
||||
282
client/internal/filedrop/offer.go
Normal file
282
client/internal/filedrop/offer.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Offer is one incoming transfer as tracked by the receiver.
|
||||
type Offer struct {
|
||||
ID OfferID
|
||||
Sender PeerKey
|
||||
SenderName string
|
||||
Files []FileMeta
|
||||
Decision Decision
|
||||
State State
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
Progress []int64
|
||||
}
|
||||
|
||||
type offerEntry struct {
|
||||
offer Offer
|
||||
decided chan struct{}
|
||||
}
|
||||
|
||||
// OfferStore tracks incoming offers and their decisions.
|
||||
type OfferStore struct {
|
||||
mu sync.RWMutex
|
||||
offers map[OfferID]*offerEntry
|
||||
ttl time.Duration
|
||||
newID func() OfferID
|
||||
nowFunc func() time.Time
|
||||
}
|
||||
|
||||
// NewOfferStore returns an empty store using ttl as the decision deadline.
|
||||
func NewOfferStore(ttl time.Duration) *OfferStore {
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultOfferTTL
|
||||
}
|
||||
return &OfferStore{
|
||||
offers: make(map[OfferID]*offerEntry),
|
||||
ttl: ttl,
|
||||
newID: func() OfferID { return OfferID(uuid.NewString()) },
|
||||
nowFunc: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Add registers a new offer with the given initial decision and returns its snapshot.
|
||||
func (s *OfferStore) Add(sender PeerKey, senderName string, files []FileMeta, decision Decision) Offer {
|
||||
now := s.nowFunc()
|
||||
|
||||
entry := &offerEntry{
|
||||
offer: Offer{
|
||||
ID: s.newID(),
|
||||
Sender: sender,
|
||||
SenderName: senderName,
|
||||
Files: files,
|
||||
Decision: decision,
|
||||
State: stateForDecision(decision),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(s.ttl),
|
||||
Progress: make([]int64, len(files)),
|
||||
},
|
||||
decided: make(chan struct{}),
|
||||
}
|
||||
if decision != DecisionPending {
|
||||
close(entry.decided)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.offers[entry.offer.ID] = entry
|
||||
s.mu.Unlock()
|
||||
|
||||
return entry.offer.clone()
|
||||
}
|
||||
|
||||
// Get returns a snapshot of one offer belonging to sender.
|
||||
func (s *OfferStore) Get(sender PeerKey, id OfferID) (Offer, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
entry, ok := s.offers[id]
|
||||
if !ok || entry.offer.Sender != sender {
|
||||
return Offer{}, false
|
||||
}
|
||||
return entry.offer.clone(), true
|
||||
}
|
||||
|
||||
// List returns snapshots of every tracked offer.
|
||||
func (s *OfferStore) List() []Offer {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
offers := make([]Offer, 0, len(s.offers))
|
||||
for _, entry := range s.offers {
|
||||
offers = append(offers, entry.offer.clone())
|
||||
}
|
||||
return offers
|
||||
}
|
||||
|
||||
// Decide records the receiver's answer; a made decision is final.
|
||||
func (s *OfferStore) Decide(id OfferID, decision Decision) (Offer, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, ok := s.offers[id]
|
||||
if !ok || entry.offer.Decision != DecisionPending {
|
||||
return Offer{}, false
|
||||
}
|
||||
|
||||
entry.offer.Decision = decision
|
||||
entry.offer.State = stateForDecision(decision)
|
||||
if decision == DecisionAccepted && entry.offer.awaitsNoUpload() {
|
||||
entry.offer.State = StateCompleted
|
||||
}
|
||||
close(entry.decided)
|
||||
|
||||
return entry.offer.clone(), true
|
||||
}
|
||||
|
||||
// Await blocks until a decision, expiry, or ctx cancellation.
|
||||
func (s *OfferStore) Await(ctx context.Context, sender PeerKey, id OfferID) (Offer, error) {
|
||||
s.mu.RLock()
|
||||
entry, ok := s.offers[id]
|
||||
if ok && entry.offer.Sender != sender {
|
||||
ok = false
|
||||
}
|
||||
var decided chan struct{}
|
||||
var expiresAt time.Time
|
||||
if ok {
|
||||
decided = entry.decided
|
||||
expiresAt = entry.offer.ExpiresAt
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return Offer{}, ErrOfferNotFound
|
||||
}
|
||||
|
||||
timer := time.NewTimer(time.Until(expiresAt))
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-decided:
|
||||
case <-timer.C:
|
||||
s.Decide(id, DecisionExpired)
|
||||
case <-ctx.Done():
|
||||
offer, _ := s.Get(sender, id)
|
||||
return offer, ctx.Err()
|
||||
}
|
||||
|
||||
offer, ok := s.Get(sender, id)
|
||||
if !ok {
|
||||
return Offer{}, ErrOfferNotFound
|
||||
}
|
||||
return offer, nil
|
||||
}
|
||||
|
||||
// SetProgress records the staged byte count for one file of an offer.
|
||||
func (s *OfferStore) SetProgress(id OfferID, index int, received int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, ok := s.offers[id]
|
||||
if !ok || index < 0 || index >= len(entry.offer.Progress) {
|
||||
return
|
||||
}
|
||||
entry.offer.Progress[index] = received
|
||||
if entry.offer.State == StatePending {
|
||||
entry.offer.State = StateTransferring
|
||||
}
|
||||
}
|
||||
|
||||
// SetState overrides the transfer state, for completion and failure reporting.
|
||||
func (s *OfferStore) SetState(id OfferID, state State) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if entry, ok := s.offers[id]; ok {
|
||||
entry.offer.State = state
|
||||
}
|
||||
}
|
||||
|
||||
// Remove drops an offer from the store.
|
||||
func (s *OfferStore) Remove(id OfferID) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.offers, id)
|
||||
}
|
||||
|
||||
// ExpireOverdue marks every pending offer past its deadline as expired and returns them.
|
||||
func (s *OfferStore) ExpireOverdue() []Offer {
|
||||
now := s.nowFunc()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var expired []Offer
|
||||
for _, entry := range s.offers {
|
||||
if entry.offer.Decision != DecisionPending || now.Before(entry.offer.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
entry.offer.Decision = DecisionExpired
|
||||
entry.offer.State = StateExpired
|
||||
close(entry.decided)
|
||||
expired = append(expired, entry.offer.clone())
|
||||
}
|
||||
return expired
|
||||
}
|
||||
|
||||
// Complete marks an offer completed once every file reached its announced size.
|
||||
func (s *OfferStore) Complete(id OfferID) (Offer, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, ok := s.offers[id]
|
||||
if !ok || entry.offer.State == StateCompleted {
|
||||
return Offer{}, false
|
||||
}
|
||||
|
||||
if !entry.offer.fullyStaged() {
|
||||
return Offer{}, false
|
||||
}
|
||||
|
||||
entry.offer.State = StateCompleted
|
||||
return entry.offer.clone(), true
|
||||
}
|
||||
|
||||
func (o Offer) awaitsNoUpload() bool {
|
||||
for _, f := range o.Files {
|
||||
if f.Kind != KindText {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (o Offer) fullyStaged() bool {
|
||||
for i, f := range o.Files {
|
||||
if f.Kind == KindText {
|
||||
continue
|
||||
}
|
||||
if i >= len(o.Progress) || o.Progress[i] < f.Size {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (o Offer) clone() Offer {
|
||||
c := o
|
||||
c.Files = make([]FileMeta, len(o.Files))
|
||||
copy(c.Files, o.Files)
|
||||
c.Progress = make([]int64, len(o.Progress))
|
||||
copy(c.Progress, o.Progress)
|
||||
return c
|
||||
}
|
||||
|
||||
// TotalSize is the announced byte count across every file of the offer.
|
||||
func (o Offer) TotalSize() int64 {
|
||||
var total int64
|
||||
for _, f := range o.Files {
|
||||
total += f.Size
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func stateForDecision(d Decision) State {
|
||||
switch d {
|
||||
case DecisionAccepted:
|
||||
return StateTransferring
|
||||
case DecisionDeclined:
|
||||
return StateDeclined
|
||||
case DecisionExpired:
|
||||
return StateExpired
|
||||
default:
|
||||
return StatePending
|
||||
}
|
||||
}
|
||||
206
client/internal/filedrop/policy.go
Normal file
206
client/internal/filedrop/policy.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
const (
|
||||
SenderRuleDefault SenderRule = iota
|
||||
SenderRuleAlwaysAccept
|
||||
SenderRuleBlock
|
||||
)
|
||||
|
||||
// SenderRule is a per-sender override on top of the base mode.
|
||||
type SenderRule uint8
|
||||
|
||||
// Policy is the device-local receiving policy of one profile. An empty
|
||||
// DestinationDir means the platform's default download directory.
|
||||
type Policy struct {
|
||||
Mode Mode `json:"mode"`
|
||||
Senders map[PeerKey]SenderRule `json:"senders,omitempty"`
|
||||
DestinationDir string `json:"destinationDir,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyStore holds the receiving policy of one profile and evaluates it per sender.
|
||||
type PolicyStore struct {
|
||||
mu sync.RWMutex
|
||||
profile profilemanager.ID
|
||||
policy Policy
|
||||
store Store
|
||||
}
|
||||
|
||||
// NewPolicyStore returns an in-memory store seeded with the default policy.
|
||||
func NewPolicyStore(profile profilemanager.ID) *PolicyStore {
|
||||
return &PolicyStore{profile: profile, policy: DefaultPolicy()}
|
||||
}
|
||||
|
||||
// LoadPolicyStore builds a store from the persisted policy of one profile.
|
||||
func LoadPolicyStore(profile profilemanager.ID, store Store) *PolicyStore {
|
||||
s := &PolicyStore{
|
||||
profile: profile,
|
||||
policy: DefaultPolicy(),
|
||||
store: store,
|
||||
}
|
||||
if store == nil {
|
||||
return s
|
||||
}
|
||||
|
||||
policy := DefaultPolicy()
|
||||
if err := loadSection(store, namespacePolicy, &policy); err != nil {
|
||||
log.Warnf("failed to load file drop policy for profile %s, using defaults: %v", profile, err)
|
||||
return s
|
||||
}
|
||||
if err := policy.validate(); err != nil {
|
||||
log.Warnf("stored file drop policy for profile %s is invalid, using defaults: %v", profile, err)
|
||||
return s
|
||||
}
|
||||
|
||||
s.policy = policy.normalized()
|
||||
return s
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (r SenderRule) String() string {
|
||||
switch r {
|
||||
case SenderRuleDefault:
|
||||
return "default"
|
||||
case SenderRuleAlwaysAccept:
|
||||
return "always"
|
||||
case SenderRuleBlock:
|
||||
return "block"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", uint8(r))
|
||||
}
|
||||
}
|
||||
|
||||
func (p Policy) validate() error {
|
||||
if !p.Mode.valid() {
|
||||
return fmt.Errorf("invalid mode %s", p.Mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Policy) normalized() Policy {
|
||||
c := p.clone()
|
||||
if c.Senders == nil {
|
||||
c.Senders = map[PeerKey]SenderRule{}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (p Policy) clone() Policy {
|
||||
c := p
|
||||
c.Senders = make(map[PeerKey]SenderRule, len(p.Senders))
|
||||
for k, v := range p.Senders {
|
||||
c.Senders[k] = v
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Profile returns the profile this policy belongs to.
|
||||
func (s *PolicyStore) Profile() profilemanager.ID {
|
||||
return s.profile
|
||||
}
|
||||
|
||||
// Get returns a copy of the current policy.
|
||||
func (s *PolicyStore) Get() Policy {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.policy.clone()
|
||||
}
|
||||
|
||||
// Set replaces the policy and persists it.
|
||||
func (s *PolicyStore) Set(p Policy) error {
|
||||
if err := p.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.policy = p.normalized()
|
||||
store, stored := s.store, s.policy.clone()
|
||||
s.mu.Unlock()
|
||||
|
||||
return saveSection(store, namespacePolicy, stored)
|
||||
}
|
||||
|
||||
// SetMode changes the base mode, leaving per-sender rules untouched.
|
||||
func (s *PolicyStore) SetMode(m Mode) error {
|
||||
if !m.valid() {
|
||||
return fmt.Errorf("invalid mode %s", m)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.policy.Mode = m
|
||||
store, stored := s.store, s.policy.clone()
|
||||
s.mu.Unlock()
|
||||
|
||||
return saveSection(store, namespacePolicy, stored)
|
||||
}
|
||||
|
||||
// SetSenderRule sets or clears the override for a single sender.
|
||||
func (s *PolicyStore) SetSenderRule(key PeerKey, rule SenderRule) error {
|
||||
s.mu.Lock()
|
||||
if rule == SenderRuleDefault {
|
||||
delete(s.policy.Senders, key)
|
||||
} else {
|
||||
if s.policy.Senders == nil {
|
||||
s.policy.Senders = map[PeerKey]SenderRule{}
|
||||
}
|
||||
s.policy.Senders[key] = rule
|
||||
}
|
||||
store, stored := s.store, s.policy.clone()
|
||||
s.mu.Unlock()
|
||||
|
||||
return saveSection(store, namespacePolicy, stored)
|
||||
}
|
||||
|
||||
// DestinationDir returns the directory received files are delivered to.
|
||||
func (s *PolicyStore) DestinationDir() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.policy.DestinationDir
|
||||
}
|
||||
|
||||
// SetDestinationDir persists the delivery directory.
|
||||
func (s *PolicyStore) SetDestinationDir(dir string) error {
|
||||
s.mu.Lock()
|
||||
s.policy.DestinationDir = dir
|
||||
store, stored := s.store, s.policy.clone()
|
||||
s.mu.Unlock()
|
||||
|
||||
return saveSection(store, namespacePolicy, stored)
|
||||
}
|
||||
|
||||
// Evaluate returns the mode that applies to one sender, denying on unknown values.
|
||||
func (s *PolicyStore) Evaluate(key PeerKey) Mode {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
switch s.policy.Senders[key] {
|
||||
case SenderRuleBlock:
|
||||
return ModeOff
|
||||
case SenderRuleAlwaysAccept:
|
||||
return ModeAutoAccept
|
||||
case SenderRuleDefault:
|
||||
default:
|
||||
return ModeOff
|
||||
}
|
||||
|
||||
if !s.policy.Mode.valid() {
|
||||
return ModeOff
|
||||
}
|
||||
return s.policy.Mode
|
||||
}
|
||||
|
||||
// DefaultPolicy asks before accepting anything, so receiving is never silently on.
|
||||
func DefaultPolicy() Policy {
|
||||
return Policy{
|
||||
Mode: ModeAsk,
|
||||
Senders: map[PeerKey]SenderRule{},
|
||||
}
|
||||
}
|
||||
81
client/internal/filedrop/ports.go
Normal file
81
client/internal/filedrop/ports.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// PortRegistry tracks the file drop listen port each peer advertised over
|
||||
// signaling; 0 means the well-known default. Senders can wait on it to learn a
|
||||
// better port after a failed attempt.
|
||||
type PortRegistry struct {
|
||||
mu sync.Mutex
|
||||
ports map[PeerKey]uint16
|
||||
waits map[PeerKey][]chan uint16
|
||||
}
|
||||
|
||||
// NewPortRegistry returns an empty registry.
|
||||
func NewPortRegistry() *PortRegistry {
|
||||
return &PortRegistry{
|
||||
ports: make(map[PeerKey]uint16),
|
||||
waits: make(map[PeerKey][]chan uint16),
|
||||
}
|
||||
}
|
||||
|
||||
// Set records the port a peer advertised and releases every waiter for it.
|
||||
func (r *PortRegistry) Set(key PeerKey, port uint16) {
|
||||
r.mu.Lock()
|
||||
r.ports[key] = port
|
||||
waiters := r.waits[key]
|
||||
delete(r.waits, key)
|
||||
r.mu.Unlock()
|
||||
|
||||
for _, ch := range waiters {
|
||||
ch <- port
|
||||
}
|
||||
}
|
||||
|
||||
// Port returns the last advertised port for a peer; 0 means default or unknown.
|
||||
func (r *PortRegistry) Port(key PeerKey) uint16 {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.ports[key]
|
||||
}
|
||||
|
||||
// Await returns the peer's port as soon as it differs from used, or after the next
|
||||
// advertisement even when it does not, reporting whether it differs. It returns
|
||||
// immediately when the currently known port already differs.
|
||||
func (r *PortRegistry) Await(ctx context.Context, key PeerKey, used uint16) (uint16, bool) {
|
||||
r.mu.Lock()
|
||||
if port, ok := r.ports[key]; ok && port != used {
|
||||
r.mu.Unlock()
|
||||
return port, true
|
||||
}
|
||||
ch := make(chan uint16, 1)
|
||||
r.waits[key] = append(r.waits[key], ch)
|
||||
r.mu.Unlock()
|
||||
|
||||
select {
|
||||
case port := <-ch:
|
||||
return port, port != used
|
||||
case <-ctx.Done():
|
||||
r.drop(key, ch)
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PortRegistry) drop(key PeerKey, ch chan uint16) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
waiters := r.waits[key]
|
||||
for i, w := range waiters {
|
||||
if w == ch {
|
||||
r.waits[key] = append(waiters[:i], waiters[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(r.waits[key]) == 0 {
|
||||
delete(r.waits, key)
|
||||
}
|
||||
}
|
||||
197
client/internal/filedrop/protocol.go
Normal file
197
client/internal/filedrop/protocol.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const Port uint16 = 41421
|
||||
|
||||
// HeaderReceivedBytes carries the receiver's confirmed byte count in a HEAD response.
|
||||
const HeaderReceivedBytes = "Netbird-Received-Bytes"
|
||||
|
||||
// DefaultOfferTTL bounds how long an offer waits for the receiver's decision.
|
||||
const DefaultOfferTTL = 5 * time.Minute
|
||||
|
||||
// MaxOfferFiles bounds the number of items a single offer may announce.
|
||||
const MaxOfferFiles = 512
|
||||
|
||||
// MaxInlineTextSize bounds an inline text snippet, which is held in memory.
|
||||
const MaxInlineTextSize = 64 * 1024
|
||||
|
||||
const maxOfferBodySize = 1 << 20
|
||||
|
||||
const (
|
||||
pathOffers = "/v1/offers"
|
||||
pathOffersSlash = pathOffers + "/"
|
||||
|
||||
segmentFiles = "files"
|
||||
)
|
||||
|
||||
// The decisions an offer can carry. Pending is the only non-final one.
|
||||
const (
|
||||
DecisionPending Decision = iota
|
||||
DecisionAccepted
|
||||
DecisionDeclined
|
||||
DecisionExpired
|
||||
)
|
||||
|
||||
// The receiving modes a profile can be in.
|
||||
const (
|
||||
ModeOff Mode = iota
|
||||
ModeAsk
|
||||
ModeAutoAccept
|
||||
)
|
||||
|
||||
// The payload kinds an offer can announce.
|
||||
const (
|
||||
KindFile Kind = iota
|
||||
KindText
|
||||
)
|
||||
|
||||
// The states a transfer moves through.
|
||||
const (
|
||||
StatePending State = iota
|
||||
StateTransferring
|
||||
StateCompleted
|
||||
StateDeclined
|
||||
StateExpired
|
||||
StateCancelled
|
||||
StateFailed
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOfferNotFound = errors.New("offer not found")
|
||||
ErrRefused = errors.New("offer refused by receiver")
|
||||
ErrDeclined = errors.New("offer declined")
|
||||
ErrExpired = errors.New("offer expired")
|
||||
ErrNotAccepted = errors.New("offer not accepted")
|
||||
ErrUnknownPeer = errors.New("unknown peer")
|
||||
ErrInvalidOffer = errors.New("invalid offer")
|
||||
)
|
||||
|
||||
// OfferID identifies a single transfer offer on the receiving peer.
|
||||
type OfferID string
|
||||
|
||||
// PeerKey is the remote peer's public key, used as the identity for per-sender policy.
|
||||
type PeerKey string
|
||||
|
||||
// Decision is the receiver's answer to an offer.
|
||||
type Decision uint8
|
||||
|
||||
// Mode is the receiver's profile-local policy for incoming offers.
|
||||
type Mode uint8
|
||||
|
||||
// Kind distinguishes payloads that are written to the spool from inline text snippets.
|
||||
type Kind uint8
|
||||
|
||||
// State is the lifecycle state of a transfer, on either side.
|
||||
type State uint8
|
||||
|
||||
// FileMeta describes one payload item announced in an offer.
|
||||
type FileMeta struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
Kind Kind `json:"kind,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// OfferRequest is the JSON body of POST /v1/offers. It carries metadata only.
|
||||
type OfferRequest struct {
|
||||
SenderName string `json:"senderName,omitempty"`
|
||||
Files []FileMeta `json:"files"`
|
||||
}
|
||||
|
||||
// OfferResponse is returned for an offer and for every poll of its status.
|
||||
type OfferResponse struct {
|
||||
ID OfferID `json:"id"`
|
||||
Decision Decision `json:"decision"`
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (d Decision) String() string {
|
||||
switch d {
|
||||
case DecisionPending:
|
||||
return "pending"
|
||||
case DecisionAccepted:
|
||||
return "accepted"
|
||||
case DecisionDeclined:
|
||||
return "declined"
|
||||
case DecisionExpired:
|
||||
return "expired"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", uint8(d))
|
||||
}
|
||||
}
|
||||
|
||||
func (d Decision) valid() bool {
|
||||
switch d {
|
||||
case DecisionPending, DecisionAccepted, DecisionDeclined, DecisionExpired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (m Mode) String() string {
|
||||
switch m {
|
||||
case ModeOff:
|
||||
return "off"
|
||||
case ModeAsk:
|
||||
return "ask"
|
||||
case ModeAutoAccept:
|
||||
return "auto"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", uint8(m))
|
||||
}
|
||||
}
|
||||
|
||||
func (m Mode) valid() bool {
|
||||
switch m {
|
||||
case ModeOff, ModeAsk, ModeAutoAccept:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (k Kind) String() string {
|
||||
switch k {
|
||||
case KindFile:
|
||||
return "file"
|
||||
case KindText:
|
||||
return "text"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", uint8(k))
|
||||
}
|
||||
}
|
||||
|
||||
func (k Kind) valid() bool {
|
||||
return k == KindFile || k == KindText
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case StatePending:
|
||||
return "pending"
|
||||
case StateTransferring:
|
||||
return "transferring"
|
||||
case StateCompleted:
|
||||
return "completed"
|
||||
case StateDeclined:
|
||||
return "declined"
|
||||
case StateExpired:
|
||||
return "expired"
|
||||
case StateCancelled:
|
||||
return "cancelled"
|
||||
case StateFailed:
|
||||
return "failed"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", uint8(s))
|
||||
}
|
||||
}
|
||||
226
client/internal/filedrop/receiver.go
Normal file
226
client/internal/filedrop/receiver.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ErrStorage indicates the receiver could not stage payload data locally.
|
||||
var ErrStorage = errors.New("storage failure")
|
||||
|
||||
type senderIdentity struct {
|
||||
key PeerKey
|
||||
name string
|
||||
}
|
||||
|
||||
// receiver implements the transfer protocol independent of any transport. Every
|
||||
// operation takes the already-authenticated sender identity and returns domain
|
||||
// errors for the transport to map.
|
||||
type receiver struct {
|
||||
policy *PolicyStore
|
||||
resolver PeerResolver
|
||||
notifier Notifier
|
||||
offers *OfferStore
|
||||
spool *Spool
|
||||
spoolMaxAge time.Duration
|
||||
}
|
||||
|
||||
func newReceiver(cfg ServerConfig, spool *Spool, maxAge time.Duration) *receiver {
|
||||
return &receiver{
|
||||
policy: cfg.Policy,
|
||||
resolver: cfg.Resolver,
|
||||
notifier: cfg.Notifier,
|
||||
offers: NewOfferStore(cfg.OfferTTL),
|
||||
spool: spool,
|
||||
spoolMaxAge: maxAge,
|
||||
}
|
||||
}
|
||||
|
||||
// identify maps a source overlay address to a known peer, refusing unknown ones.
|
||||
func (r *receiver) identify(addr netip.Addr) (senderIdentity, bool) {
|
||||
key, name, ok := r.resolver.ResolvePeer(addr.Unmap())
|
||||
if !ok {
|
||||
return senderIdentity{}, false
|
||||
}
|
||||
return senderIdentity{key: key, name: name}, true
|
||||
}
|
||||
|
||||
func (r *receiver) submitOffer(sender senderIdentity, req OfferRequest) (Offer, error) {
|
||||
if err := validateOffer(req.Files); err != nil {
|
||||
return Offer{}, fmt.Errorf("%w: %s", ErrInvalidOffer, err)
|
||||
}
|
||||
|
||||
mode := r.policy.Evaluate(sender.key)
|
||||
if mode == ModeOff {
|
||||
return Offer{}, ErrRefused
|
||||
}
|
||||
|
||||
senderName := sender.name
|
||||
if senderName == "" {
|
||||
senderName = req.SenderName
|
||||
}
|
||||
|
||||
decision := DecisionPending
|
||||
if mode == ModeAutoAccept {
|
||||
decision = DecisionAccepted
|
||||
}
|
||||
|
||||
offer := r.offers.Add(sender.key, senderName, req.Files, decision)
|
||||
if err := r.spool.Prepare(offer.ID); err != nil {
|
||||
r.offers.Remove(offer.ID)
|
||||
log.Errorf("prepare spool for offer: %v", err)
|
||||
return Offer{}, fmt.Errorf("%w: prepare spool", ErrStorage)
|
||||
}
|
||||
|
||||
r.notifyOffer(offer)
|
||||
|
||||
if offer.Decision == DecisionAccepted {
|
||||
if completed, done := r.offers.Complete(offer.ID); done {
|
||||
r.notifyCompleted(completed)
|
||||
}
|
||||
}
|
||||
return offer, nil
|
||||
}
|
||||
|
||||
func (r *receiver) awaitDecision(ctx context.Context, sender senderIdentity, id OfferID) (Offer, error) {
|
||||
return r.offers.Await(ctx, sender.key, id)
|
||||
}
|
||||
|
||||
func (r *receiver) withdraw(sender senderIdentity, id OfferID) error {
|
||||
offer, ok := r.offers.Get(sender.key, id)
|
||||
if !ok {
|
||||
return ErrOfferNotFound
|
||||
}
|
||||
|
||||
r.offers.SetState(id, StateCancelled)
|
||||
r.offers.Remove(id)
|
||||
r.spool.Remove(id)
|
||||
|
||||
offer.State = StateCancelled
|
||||
r.notifyWithdrawn(offer)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *receiver) receivedBytes(sender senderIdentity, id OfferID, index int) (int64, error) {
|
||||
offer, ok := r.offers.Get(sender.key, id)
|
||||
if !ok || index >= len(offer.Files) {
|
||||
return 0, ErrOfferNotFound
|
||||
}
|
||||
|
||||
received, err := r.spool.Received(id, index)
|
||||
if err != nil {
|
||||
log.Debugf("probe spool file: %v", err)
|
||||
return 0, fmt.Errorf("%w: read staged size", ErrStorage)
|
||||
}
|
||||
return received, nil
|
||||
}
|
||||
|
||||
func (r *receiver) upload(sender senderIdentity, id OfferID, index int, offset int64, body io.Reader) error {
|
||||
offer, ok := r.offers.Get(sender.key, id)
|
||||
if !ok || index >= len(offer.Files) {
|
||||
return ErrOfferNotFound
|
||||
}
|
||||
if offer.Decision != DecisionAccepted {
|
||||
return ErrNotAccepted
|
||||
}
|
||||
if offer.Files[index].Kind == KindText {
|
||||
return fmt.Errorf("%w: text payloads carry no body", ErrInvalidOffer)
|
||||
}
|
||||
|
||||
size := offer.Files[index].Size
|
||||
if offset < 0 || offset > size {
|
||||
return fmt.Errorf("%w: offset out of range", ErrInvalidOffer)
|
||||
}
|
||||
|
||||
received, err := r.spool.Write(id, index, offset, body, size)
|
||||
r.offers.SetProgress(id, index, received)
|
||||
r.notifyProgress(offer, index, received)
|
||||
|
||||
if err != nil {
|
||||
r.offers.SetState(id, StateFailed)
|
||||
r.notifyFailed(offer, err)
|
||||
log.Debugf("stage payload for offer %s file %d: %v", id, index, err)
|
||||
return fmt.Errorf("%w: stage payload", ErrStorage)
|
||||
}
|
||||
|
||||
if completed, ok := r.offers.Complete(id); ok {
|
||||
r.notifyCompleted(completed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expireOverdue reclaims offers past their decision deadline and stale spool data.
|
||||
func (r *receiver) expireOverdue() {
|
||||
for _, offer := range r.offers.ExpireOverdue() {
|
||||
r.spool.Remove(offer.ID)
|
||||
r.notifyFailed(offer, ErrExpired)
|
||||
}
|
||||
r.spool.Cleanup(r.spoolMaxAge, time.Now())
|
||||
}
|
||||
|
||||
func (r *receiver) close() {
|
||||
for _, offer := range r.offers.List() {
|
||||
r.offers.Remove(offer.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *receiver) notifyOffer(offer Offer) {
|
||||
if r.notifier != nil {
|
||||
r.notifier.OnOffer(offer)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *receiver) notifyProgress(offer Offer, index int, received int64) {
|
||||
if r.notifier != nil {
|
||||
r.notifier.OnProgress(offer, index, received)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *receiver) notifyCompleted(offer Offer) {
|
||||
if r.notifier != nil {
|
||||
r.notifier.OnCompleted(offer)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *receiver) notifyFailed(offer Offer, err error) {
|
||||
if r.notifier != nil {
|
||||
r.notifier.OnFailed(offer, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *receiver) notifyWithdrawn(offer Offer) {
|
||||
if r.notifier != nil {
|
||||
r.notifier.OnWithdrawn(offer)
|
||||
}
|
||||
}
|
||||
|
||||
func validateOffer(files []FileMeta) error {
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("offer announces no files")
|
||||
}
|
||||
if len(files) > MaxOfferFiles {
|
||||
return fmt.Errorf("offer announces more than %d files", MaxOfferFiles)
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if !f.Kind.valid() {
|
||||
return fmt.Errorf("unknown payload kind %s", f.Kind)
|
||||
}
|
||||
if f.Kind == KindText {
|
||||
if len(f.Text) > MaxInlineTextSize {
|
||||
return fmt.Errorf("inline text exceeds %d bytes", MaxInlineTextSize)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if f.Size < 0 {
|
||||
return fmt.Errorf("negative file size")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
262
client/internal/filedrop/server.go
Normal file
262
client/internal/filedrop/server.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSpoolMaxAge = 24 * time.Hour
|
||||
janitorInterval = 10 * time.Minute
|
||||
readHeaderTimeout = 30 * time.Second
|
||||
idleTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// PeerResolver maps the source overlay address of a connection to the peer that owns it.
|
||||
type PeerResolver interface {
|
||||
ResolvePeer(addr netip.Addr) (key PeerKey, name string, ok bool)
|
||||
}
|
||||
|
||||
// Notifier receives receiver-side transfer events for the platform layer to surface.
|
||||
type Notifier interface {
|
||||
OnOffer(offer Offer)
|
||||
OnProgress(offer Offer, index int, received int64)
|
||||
OnCompleted(offer Offer)
|
||||
OnFailed(offer Offer, err error)
|
||||
OnWithdrawn(offer Offer)
|
||||
}
|
||||
|
||||
// ServerConfig configures the receiving side.
|
||||
type ServerConfig struct {
|
||||
SpoolDir string
|
||||
Policy *PolicyStore
|
||||
Resolver PeerResolver
|
||||
Notifier Notifier
|
||||
OfferTTL time.Duration
|
||||
SpoolMaxAge time.Duration
|
||||
}
|
||||
|
||||
// Server serves the receiver over HTTP on the overlay address and owns the
|
||||
// listener and janitor lifecycle; the protocol logic itself lives in receiver.
|
||||
type Server struct {
|
||||
mu sync.RWMutex
|
||||
httpServer *http.Server
|
||||
listener net.Listener
|
||||
extraListeners []net.Listener
|
||||
netstackNet *netstack.Net
|
||||
|
||||
recv *receiver
|
||||
boundPort uint16
|
||||
|
||||
janitorStop context.CancelFunc
|
||||
janitorDone chan struct{}
|
||||
}
|
||||
|
||||
// NewServer builds a receiving server. It does not start listening.
|
||||
func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
if cfg.Resolver == nil {
|
||||
return nil, errors.New("peer resolver is required")
|
||||
}
|
||||
if cfg.Policy == nil {
|
||||
return nil, errors.New("receiving policy is required")
|
||||
}
|
||||
|
||||
spool, err := NewSpool(cfg.SpoolDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create spool: %w", err)
|
||||
}
|
||||
|
||||
maxAge := cfg.SpoolMaxAge
|
||||
if maxAge <= 0 {
|
||||
maxAge = defaultSpoolMaxAge
|
||||
}
|
||||
|
||||
return &Server{recv: newReceiver(cfg, spool, maxAge)}, nil
|
||||
}
|
||||
|
||||
// SetNetstackNet routes listeners through the gVisor netstack instead of host sockets.
|
||||
func (s *Server) SetNetstackNet(n *netstack.Net) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.netstackNet = n
|
||||
}
|
||||
|
||||
// Offers returns the offer store, for the platform layer to accept, decline, and list.
|
||||
func (s *Server) Offers() *OfferStore {
|
||||
return s.recv.offers
|
||||
}
|
||||
|
||||
// Spool returns the staging area, so the platform layer can deliver completed payloads.
|
||||
func (s *Server) Spool() *Spool {
|
||||
return s.recv.spool
|
||||
}
|
||||
|
||||
// Policy returns the active profile's receiving policy store.
|
||||
func (s *Server) Policy() *PolicyStore {
|
||||
return s.recv.policy
|
||||
}
|
||||
|
||||
// BoundPort returns the port the server actually listens on, 0 when stopped.
|
||||
func (s *Server) BoundPort() uint16 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.boundPort
|
||||
}
|
||||
|
||||
// Start binds the service to addr and serves until Stop.
|
||||
func (s *Server) Start(ctx context.Context, addr netip.AddrPort) error {
|
||||
s.mu.Lock()
|
||||
if s.httpServer != nil {
|
||||
s.mu.Unlock()
|
||||
return errors.New("file drop server is already running")
|
||||
}
|
||||
|
||||
ln, desc, err := s.createListener(ctx, addr)
|
||||
if err != nil && addr.Port() != 0 {
|
||||
log.Warnf("file drop port %d is unavailable, falling back to a dynamic port: %v", addr.Port(), err)
|
||||
ln, desc, err = s.createListener(ctx, netip.AddrPortFrom(addr.Addr(), 0))
|
||||
}
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("create listener: %w", err)
|
||||
}
|
||||
|
||||
transport := &httpTransport{recv: s.recv}
|
||||
httpServer := &http.Server{
|
||||
Handler: transport.routes(),
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
IdleTimeout: idleTimeout,
|
||||
}
|
||||
|
||||
janitorCtx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
|
||||
s.listener = ln
|
||||
s.httpServer = httpServer
|
||||
s.boundPort = listenerPort(ln, addr.Port())
|
||||
s.janitorStop = cancel
|
||||
s.janitorDone = done
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.runJanitor(janitorCtx, done)
|
||||
go s.serve(httpServer, ln, desc)
|
||||
|
||||
log.Infof("file drop server started on %s", desc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddListener serves the running service on an additional address, such as IPv6.
|
||||
func (s *Server) AddListener(ctx context.Context, addr netip.AddrPort) error {
|
||||
s.mu.Lock()
|
||||
httpServer := s.httpServer
|
||||
if httpServer == nil {
|
||||
s.mu.Unlock()
|
||||
return errors.New("file drop server is not running")
|
||||
}
|
||||
|
||||
ln, desc, err := s.createListener(ctx, addr)
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("create listener: %w", err)
|
||||
}
|
||||
s.extraListeners = append(s.extraListeners, ln)
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.serve(httpServer, ln, desc)
|
||||
|
||||
log.Infof("file drop server also listening on %s", desc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop shuts the service down and releases the offers it was tracking. It is idempotent.
|
||||
func (s *Server) Stop() error {
|
||||
s.mu.Lock()
|
||||
httpServer := s.httpServer
|
||||
if httpServer == nil {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.httpServer = nil
|
||||
s.listener = nil
|
||||
s.boundPort = 0
|
||||
extra := s.extraListeners
|
||||
s.extraListeners = nil
|
||||
stopJanitor, janitorDone := s.janitorStop, s.janitorDone
|
||||
s.janitorStop, s.janitorDone = nil, nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if stopJanitor != nil {
|
||||
stopJanitor()
|
||||
<-janitorDone
|
||||
}
|
||||
|
||||
err := httpServer.Close()
|
||||
|
||||
for _, ln := range extra {
|
||||
if cerr := ln.Close(); cerr != nil {
|
||||
log.Debugf("close extra file drop listener: %v", cerr)
|
||||
}
|
||||
}
|
||||
|
||||
s.recv.close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("close: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) serve(httpServer *http.Server, ln net.Listener, desc string) {
|
||||
if err := httpServer.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Errorf("file drop server error on %s: %v", desc, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) createListener(ctx context.Context, addr netip.AddrPort) (net.Listener, string, error) {
|
||||
if s.netstackNet != nil {
|
||||
ln, err := s.netstackNet.ListenTCPAddrPort(addr)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("listen on netstack: %w", err)
|
||||
}
|
||||
return ln, fmt.Sprintf("netstack %s", addr), nil
|
||||
}
|
||||
|
||||
var lc net.ListenConfig
|
||||
ln, err := lc.Listen(ctx, "tcp", net.TCPAddrFromAddrPort(addr).String())
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
return ln, addr.String(), nil
|
||||
}
|
||||
|
||||
func (s *Server) runJanitor(ctx context.Context, done chan struct{}) {
|
||||
defer close(done)
|
||||
|
||||
ticker := time.NewTicker(janitorInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.recv.expireOverdue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listenerPort(ln net.Listener, requested uint16) uint16 {
|
||||
if tcpAddr, ok := ln.Addr().(*net.TCPAddr); ok {
|
||||
return uint16(tcpAddr.Port)
|
||||
}
|
||||
return requested
|
||||
}
|
||||
131
client/internal/filedrop/spool.go
Normal file
131
client/internal/filedrop/spool.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Spool stages incoming payloads in an app-private directory.
|
||||
type Spool struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// NewSpool prepares the spool directory tree under root.
|
||||
func NewSpool(root string) (*Spool, error) {
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("empty spool root")
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create spool root: %w", err)
|
||||
}
|
||||
return &Spool{root: root}, nil
|
||||
}
|
||||
|
||||
// Root returns the spool base directory.
|
||||
func (s *Spool) Root() string {
|
||||
return s.root
|
||||
}
|
||||
|
||||
// OfferDir returns the directory holding one offer's payloads.
|
||||
func (s *Spool) OfferDir(id OfferID) string {
|
||||
return filepath.Join(s.root, string(id))
|
||||
}
|
||||
|
||||
func (s *Spool) filePath(id OfferID, index int) string {
|
||||
return filepath.Join(s.OfferDir(id), strconv.Itoa(index))
|
||||
}
|
||||
|
||||
// Prepare creates the directory for an offer.
|
||||
func (s *Spool) Prepare(id OfferID) error {
|
||||
if err := os.MkdirAll(s.OfferDir(id), 0o700); err != nil {
|
||||
return fmt.Errorf("create offer dir: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Received returns how many bytes of one item are already staged.
|
||||
func (s *Spool) Received(id OfferID, index int) (int64, error) {
|
||||
info, err := os.Stat(s.filePath(id, index))
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("stat spool file: %w", err)
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
// Write appends the payload at offset, truncating any bytes past it first.
|
||||
func (s *Spool) Write(id OfferID, index int, offset int64, r io.Reader, limit int64) (int64, error) {
|
||||
if offset < 0 {
|
||||
return 0, fmt.Errorf("negative offset %d", offset)
|
||||
}
|
||||
|
||||
path := s.filePath(id, index)
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open spool file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
log.Debugf("close spool file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := f.Truncate(offset); err != nil {
|
||||
return 0, fmt.Errorf("truncate spool file: %w", err)
|
||||
}
|
||||
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
||||
return 0, fmt.Errorf("seek spool file: %w", err)
|
||||
}
|
||||
|
||||
written, err := io.Copy(f, io.LimitReader(r, limit-offset))
|
||||
if err != nil {
|
||||
return offset + written, fmt.Errorf("write spool file: %w", err)
|
||||
}
|
||||
return offset + written, nil
|
||||
}
|
||||
|
||||
// Path returns the staged path of one item for the platform layer to deliver from.
|
||||
func (s *Spool) Path(id OfferID, index int) string {
|
||||
return s.filePath(id, index)
|
||||
}
|
||||
|
||||
// Remove deletes an offer's staged payloads.
|
||||
func (s *Spool) Remove(id OfferID) {
|
||||
if err := os.RemoveAll(s.OfferDir(id)); err != nil {
|
||||
log.Debugf("remove spool dir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup removes offer directories older than maxAge.
|
||||
func (s *Spool) Cleanup(maxAge time.Duration, now time.Time) {
|
||||
entries, err := os.ReadDir(s.root)
|
||||
if err != nil {
|
||||
log.Debugf("read spool root: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
log.Debugf("stat spool entry: %v", err)
|
||||
continue
|
||||
}
|
||||
if now.Sub(info.ModTime()) < maxAge {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(s.root, entry.Name())); err != nil {
|
||||
log.Debugf("remove stale spool dir: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
client/internal/filedrop/store.go
Normal file
58
client/internal/filedrop/store.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
const (
|
||||
namespacePolicy = "filedrop"
|
||||
namespaceHistory = "filedrop-history"
|
||||
)
|
||||
|
||||
// Store persists one profile's file drop state in namespaced sections.
|
||||
type Store interface {
|
||||
Get(namespace string, v any) (bool, error)
|
||||
Put(namespace string, v any) error
|
||||
}
|
||||
|
||||
type profileStore struct {
|
||||
prefs *profilemanager.Prefs
|
||||
}
|
||||
|
||||
// NewProfileStore returns the store backed by the profile's preferences.
|
||||
func NewProfileStore(prefs *profilemanager.Prefs) Store {
|
||||
if prefs == nil {
|
||||
return nil
|
||||
}
|
||||
return &profileStore{prefs: prefs}
|
||||
}
|
||||
|
||||
func (s *profileStore) Get(namespace string, v any) (bool, error) {
|
||||
return s.prefs.Get(namespace, v)
|
||||
}
|
||||
|
||||
func (s *profileStore) Put(namespace string, v any) error {
|
||||
return s.prefs.Put(namespace, v)
|
||||
}
|
||||
|
||||
func loadSection(store Store, namespace string, v any) error {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := store.Get(namespace, v); err != nil {
|
||||
return fmt.Errorf("load %s: %w", namespace, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveSection(store Store, namespace string, v any) error {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
if err := store.Put(namespace, v); err != nil {
|
||||
return fmt.Errorf("save %s: %w", namespace, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/pion/ice/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
type Signaler struct {
|
||||
signal signal.Client
|
||||
wgPrivateKey wgtypes.Key
|
||||
filedropPort atomic.Uint32
|
||||
}
|
||||
|
||||
func NewSignaler(signal signal.Client, wgPrivateKey wgtypes.Key) *Signaler {
|
||||
@@ -44,6 +47,12 @@ func (s *Signaler) Ready() bool {
|
||||
return s.signal.Ready()
|
||||
}
|
||||
|
||||
// SetFiledropPort sets the file drop listen port advertised in offers and answers;
|
||||
// 0 means the well-known default and is not put on the wire.
|
||||
func (s *Signaler) SetFiledropPort(port uint16) {
|
||||
s.filedropPort.Store(uint32(port))
|
||||
}
|
||||
|
||||
// SignalOfferAnswer signals either an offer or an answer to remote peer
|
||||
func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string, bodyType sProto.Body_Type) error {
|
||||
var sessionIDBytes []byte
|
||||
@@ -57,6 +66,7 @@ func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string,
|
||||
msg, err := signal.MarshalCredential(s.wgPrivateKey, remoteKey, signal.CredentialPayload{
|
||||
Type: bodyType,
|
||||
WgListenPort: offerAnswer.WgListenPort,
|
||||
FiledropPort: uint16(s.filedropPort.Load()),
|
||||
Credential: &signal.Credential{
|
||||
UFrag: offerAnswer.IceCredentials.UFrag,
|
||||
Pwd: offerAnswer.IceCredentials.Pwd,
|
||||
|
||||
130
client/internal/profilemanager/prefs.go
Normal file
130
client/internal/profilemanager/prefs.go
Normal file
@@ -0,0 +1,130 @@
|
||||
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, §ions); 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
|
||||
}
|
||||
138
client/internal/profilemanager/prefs_test.go
Normal file
138
client/internal/profilemanager/prefs_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
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")
|
||||
})
|
||||
}
|
||||
@@ -420,6 +420,11 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1123,6 +1123,198 @@ func local_request_DaemonService_WailsUIReady_0(ctx context.Context, marshaler r
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_FileDropSend_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropSendRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.FileDropSend(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_FileDropSend_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropSendRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.FileDropSend(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_FileDropDecide_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropDecideRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.FileDropDecide(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_FileDropDecide_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropDecideRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.FileDropDecide(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_FileDropCancel_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropCancelRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.FileDropCancel(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_FileDropCancel_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropCancelRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.FileDropCancel(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_FileDropListTransfers_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropListTransfersRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.FileDropListTransfers(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_FileDropListTransfers_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropListTransfersRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.FileDropListTransfers(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_FileDropDeleteTransfer_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropDeleteTransferRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.FileDropDeleteTransfer(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_FileDropDeleteTransfer_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropDeleteTransferRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.FileDropDeleteTransfer(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_FileDropGetSettings_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropGetSettingsRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.FileDropGetSettings(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_FileDropGetSettings_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropGetSettingsRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.FileDropGetSettings(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_FileDropSetSettings_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropSetSettingsRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.FileDropSetSettings(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_FileDropSetSettings_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropSetSettingsRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.FileDropSetSettings(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_FileDropSetPeerRule_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropSetPeerRuleRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.FileDropSetPeerRule(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_FileDropSetPeerRule_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq FileDropSetPeerRuleRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.FileDropSetPeerRule(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
// RegisterDaemonServiceHandlerServer registers the http handlers for service DaemonService to "mux".
|
||||
// UnaryRPC :call DaemonServiceServer directly.
|
||||
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
|
||||
@@ -1997,6 +2189,166 @@ func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeM
|
||||
}
|
||||
forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSend_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropSend", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSend"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_FileDropSend_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropSend_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropDecide_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropDecide", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropDecide"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_FileDropDecide_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropDecide_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropCancel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropCancel", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropCancel"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_FileDropCancel_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropCancel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropListTransfers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropListTransfers", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropListTransfers"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_FileDropListTransfers_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropListTransfers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropDeleteTransfer_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropDeleteTransfer", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropDeleteTransfer"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_FileDropDeleteTransfer_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropDeleteTransfer_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropGetSettings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropGetSettings", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropGetSettings"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_FileDropGetSettings_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropGetSettings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSetSettings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropSetSettings", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSetSettings"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_FileDropSetSettings_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropSetSettings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSetPeerRule_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/FileDropSetPeerRule", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSetPeerRule"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_FileDropSetPeerRule_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropSetPeerRule_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -2819,6 +3171,142 @@ func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeM
|
||||
}
|
||||
forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSend_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropSend", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSend"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_FileDropSend_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropSend_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropDecide_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropDecide", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropDecide"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_FileDropDecide_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropDecide_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropCancel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropCancel", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropCancel"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_FileDropCancel_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropCancel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropListTransfers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropListTransfers", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropListTransfers"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_FileDropListTransfers_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropListTransfers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropDeleteTransfer_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropDeleteTransfer", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropDeleteTransfer"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_FileDropDeleteTransfer_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropDeleteTransfer_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropGetSettings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropGetSettings", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropGetSettings"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_FileDropGetSettings_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropGetSettings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSetSettings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropSetSettings", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSetSettings"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_FileDropSetSettings_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropSetSettings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_FileDropSetPeerRule_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/FileDropSetPeerRule", runtime.WithHTTPPathPattern("/daemon.DaemonService/FileDropSetPeerRule"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_FileDropSetPeerRule_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_FileDropSetPeerRule_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2869,6 +3357,14 @@ var (
|
||||
pattern_DaemonService_GetInstallerResult_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetInstallerResult"}, ""))
|
||||
pattern_DaemonService_ExposeService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ExposeService"}, ""))
|
||||
pattern_DaemonService_WailsUIReady_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WailsUIReady"}, ""))
|
||||
pattern_DaemonService_FileDropSend_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropSend"}, ""))
|
||||
pattern_DaemonService_FileDropDecide_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropDecide"}, ""))
|
||||
pattern_DaemonService_FileDropCancel_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropCancel"}, ""))
|
||||
pattern_DaemonService_FileDropListTransfers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropListTransfers"}, ""))
|
||||
pattern_DaemonService_FileDropDeleteTransfer_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropDeleteTransfer"}, ""))
|
||||
pattern_DaemonService_FileDropGetSettings_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropGetSettings"}, ""))
|
||||
pattern_DaemonService_FileDropSetSettings_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropSetSettings"}, ""))
|
||||
pattern_DaemonService_FileDropSetPeerRule_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "FileDropSetPeerRule"}, ""))
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -2918,4 +3414,12 @@ var (
|
||||
forward_DaemonService_GetInstallerResult_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_ExposeService_0 = runtime.ForwardResponseStream
|
||||
forward_DaemonService_WailsUIReady_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_FileDropSend_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_FileDropDecide_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_FileDropCancel_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_FileDropListTransfers_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_FileDropDeleteTransfer_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_FileDropGetSettings_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_FileDropSetSettings_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_FileDropSetPeerRule_0 = runtime.ForwardResponseMessage
|
||||
)
|
||||
|
||||
@@ -156,6 +156,32 @@ service DaemonService {
|
||||
// only cares whether the daemon implements it: an Unimplemented response
|
||||
// means the daemon predates this UI and is too old to drive it.
|
||||
rpc WailsUIReady(WailsUIReadyRequest) returns (WailsUIReadyResponse) {}
|
||||
|
||||
// FileDropSend starts an asynchronous file transfer to a peer. Progress is
|
||||
// polled via FileDropListTransfers; the outcome also arrives as a SystemEvent.
|
||||
rpc FileDropSend(FileDropSendRequest) returns (FileDropSendResponse) {}
|
||||
|
||||
// FileDropDecide accepts or declines a pending incoming offer.
|
||||
rpc FileDropDecide(FileDropDecideRequest) returns (FileDropDecideResponse) {}
|
||||
|
||||
// FileDropCancel aborts a transfer in either direction.
|
||||
rpc FileDropCancel(FileDropCancelRequest) returns (FileDropCancelResponse) {}
|
||||
|
||||
// FileDropListTransfers returns the transfer history, newest first, with live
|
||||
// progress for running transfers.
|
||||
rpc FileDropListTransfers(FileDropListTransfersRequest) returns (FileDropListTransfersResponse) {}
|
||||
|
||||
// FileDropDeleteTransfer removes one entry from the transfer history.
|
||||
rpc FileDropDeleteTransfer(FileDropDeleteTransferRequest) returns (FileDropDeleteTransferResponse) {}
|
||||
|
||||
// FileDropGetSettings returns the active profile's receiving policy.
|
||||
rpc FileDropGetSettings(FileDropGetSettingsRequest) returns (FileDropGetSettingsResponse) {}
|
||||
|
||||
// FileDropSetSettings updates the active profile's receiving policy.
|
||||
rpc FileDropSetSettings(FileDropSetSettingsRequest) returns (FileDropSetSettingsResponse) {}
|
||||
|
||||
// FileDropSetPeerRule sets or clears a per-sender exception.
|
||||
rpc FileDropSetPeerRule(FileDropSetPeerRuleRequest) returns (FileDropSetPeerRuleResponse) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -1063,3 +1089,119 @@ message StartBundleCaptureRequest {
|
||||
message StartBundleCaptureResponse {}
|
||||
message StopBundleCaptureRequest {}
|
||||
message StopBundleCaptureResponse {}
|
||||
|
||||
// FileDropMode is the device-local policy for incoming file offers.
|
||||
enum FileDropMode {
|
||||
FILE_DROP_MODE_OFF = 0;
|
||||
FILE_DROP_MODE_ASK = 1;
|
||||
FILE_DROP_MODE_AUTO = 2;
|
||||
}
|
||||
|
||||
// FileDropRule is a per-sender exception on top of the base mode.
|
||||
enum FileDropRule {
|
||||
FILE_DROP_RULE_DEFAULT = 0;
|
||||
FILE_DROP_RULE_ALWAYS = 1;
|
||||
FILE_DROP_RULE_BLOCK = 2;
|
||||
}
|
||||
|
||||
// FileDropReason classifies why a transfer failed, when it is known.
|
||||
enum FileDropReason {
|
||||
FILE_DROP_REASON_NONE = 0;
|
||||
FILE_DROP_REASON_UNREACHABLE = 1;
|
||||
}
|
||||
|
||||
// FileDropState is the lifecycle state of a transfer.
|
||||
enum FileDropState {
|
||||
FILE_DROP_STATE_PENDING = 0;
|
||||
FILE_DROP_STATE_TRANSFERRING = 1;
|
||||
FILE_DROP_STATE_COMPLETED = 2;
|
||||
FILE_DROP_STATE_DECLINED = 3;
|
||||
FILE_DROP_STATE_EXPIRED = 4;
|
||||
FILE_DROP_STATE_CANCELLED = 5;
|
||||
FILE_DROP_STATE_FAILED = 6;
|
||||
}
|
||||
|
||||
message FileDropFile {
|
||||
string name = 1;
|
||||
int64 size = 2;
|
||||
string contentType = 3;
|
||||
bool isText = 4;
|
||||
// text carries an inline snippet; it never becomes a file on disk.
|
||||
string text = 5;
|
||||
}
|
||||
|
||||
message FileDropTransfer {
|
||||
string id = 1;
|
||||
bool outgoing = 2;
|
||||
string peerKey = 3;
|
||||
string peerName = 4;
|
||||
repeated FileDropFile files = 5;
|
||||
FileDropState state = 6;
|
||||
int64 transferred = 7;
|
||||
int64 totalSize = 8;
|
||||
google.protobuf.Timestamp createdAt = 9;
|
||||
google.protobuf.Timestamp updatedAt = 10;
|
||||
repeated string deliveredPaths = 11;
|
||||
string error = 12;
|
||||
FileDropReason reason = 13;
|
||||
}
|
||||
|
||||
message FileDropSendRequest {
|
||||
string peerKey = 1;
|
||||
// paths are local files to send; the daemon opens them as the caller.
|
||||
repeated string paths = 2;
|
||||
// text is an inline snippet payload, sent instead of or alongside files.
|
||||
string text = 3;
|
||||
}
|
||||
|
||||
message FileDropSendResponse {
|
||||
string transferId = 1;
|
||||
}
|
||||
|
||||
message FileDropDecideRequest {
|
||||
string transferId = 1;
|
||||
bool accept = 2;
|
||||
}
|
||||
|
||||
message FileDropDecideResponse {}
|
||||
|
||||
message FileDropCancelRequest {
|
||||
string transferId = 1;
|
||||
}
|
||||
|
||||
message FileDropCancelResponse {}
|
||||
|
||||
message FileDropListTransfersRequest {}
|
||||
|
||||
message FileDropListTransfersResponse {
|
||||
repeated FileDropTransfer transfers = 1;
|
||||
}
|
||||
|
||||
message FileDropDeleteTransferRequest {
|
||||
string transferId = 1;
|
||||
}
|
||||
|
||||
message FileDropDeleteTransferResponse {}
|
||||
|
||||
message FileDropGetSettingsRequest {}
|
||||
|
||||
message FileDropGetSettingsResponse {
|
||||
FileDropMode mode = 1;
|
||||
string destinationDir = 2;
|
||||
// peerRules is keyed by the peer's public key.
|
||||
map<string, FileDropRule> peerRules = 3;
|
||||
}
|
||||
|
||||
message FileDropSetSettingsRequest {
|
||||
FileDropMode mode = 1;
|
||||
string destinationDir = 2;
|
||||
}
|
||||
|
||||
message FileDropSetSettingsResponse {}
|
||||
|
||||
message FileDropSetPeerRuleRequest {
|
||||
string peerKey = 1;
|
||||
FileDropRule rule = 2;
|
||||
}
|
||||
|
||||
message FileDropSetPeerRuleResponse {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v6.33.1
|
||||
// - protoc v7.34.1
|
||||
// source: daemon.proto
|
||||
|
||||
package proto
|
||||
@@ -65,6 +65,14 @@ const (
|
||||
DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult"
|
||||
DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService"
|
||||
DaemonService_WailsUIReady_FullMethodName = "/daemon.DaemonService/WailsUIReady"
|
||||
DaemonService_FileDropSend_FullMethodName = "/daemon.DaemonService/FileDropSend"
|
||||
DaemonService_FileDropDecide_FullMethodName = "/daemon.DaemonService/FileDropDecide"
|
||||
DaemonService_FileDropCancel_FullMethodName = "/daemon.DaemonService/FileDropCancel"
|
||||
DaemonService_FileDropListTransfers_FullMethodName = "/daemon.DaemonService/FileDropListTransfers"
|
||||
DaemonService_FileDropDeleteTransfer_FullMethodName = "/daemon.DaemonService/FileDropDeleteTransfer"
|
||||
DaemonService_FileDropGetSettings_FullMethodName = "/daemon.DaemonService/FileDropGetSettings"
|
||||
DaemonService_FileDropSetSettings_FullMethodName = "/daemon.DaemonService/FileDropSetSettings"
|
||||
DaemonService_FileDropSetPeerRule_FullMethodName = "/daemon.DaemonService/FileDropSetPeerRule"
|
||||
)
|
||||
|
||||
// DaemonServiceClient is the client API for DaemonService service.
|
||||
@@ -171,6 +179,24 @@ type DaemonServiceClient interface {
|
||||
// only cares whether the daemon implements it: an Unimplemented response
|
||||
// means the daemon predates this UI and is too old to drive it.
|
||||
WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error)
|
||||
// FileDropSend starts an asynchronous file transfer to a peer. Progress is
|
||||
// polled via FileDropListTransfers; the outcome also arrives as a SystemEvent.
|
||||
FileDropSend(ctx context.Context, in *FileDropSendRequest, opts ...grpc.CallOption) (*FileDropSendResponse, error)
|
||||
// FileDropDecide accepts or declines a pending incoming offer.
|
||||
FileDropDecide(ctx context.Context, in *FileDropDecideRequest, opts ...grpc.CallOption) (*FileDropDecideResponse, error)
|
||||
// FileDropCancel aborts a transfer in either direction.
|
||||
FileDropCancel(ctx context.Context, in *FileDropCancelRequest, opts ...grpc.CallOption) (*FileDropCancelResponse, error)
|
||||
// FileDropListTransfers returns the transfer history, newest first, with live
|
||||
// progress for running transfers.
|
||||
FileDropListTransfers(ctx context.Context, in *FileDropListTransfersRequest, opts ...grpc.CallOption) (*FileDropListTransfersResponse, error)
|
||||
// FileDropDeleteTransfer removes one entry from the transfer history.
|
||||
FileDropDeleteTransfer(ctx context.Context, in *FileDropDeleteTransferRequest, opts ...grpc.CallOption) (*FileDropDeleteTransferResponse, error)
|
||||
// FileDropGetSettings returns the active profile's receiving policy.
|
||||
FileDropGetSettings(ctx context.Context, in *FileDropGetSettingsRequest, opts ...grpc.CallOption) (*FileDropGetSettingsResponse, error)
|
||||
// FileDropSetSettings updates the active profile's receiving policy.
|
||||
FileDropSetSettings(ctx context.Context, in *FileDropSetSettingsRequest, opts ...grpc.CallOption) (*FileDropSetSettingsResponse, error)
|
||||
// FileDropSetPeerRule sets or clears a per-sender exception.
|
||||
FileDropSetPeerRule(ctx context.Context, in *FileDropSetPeerRuleRequest, opts ...grpc.CallOption) (*FileDropSetPeerRuleResponse, error)
|
||||
}
|
||||
|
||||
type daemonServiceClient struct {
|
||||
@@ -677,6 +703,86 @@ func (c *daemonServiceClient) WailsUIReady(ctx context.Context, in *WailsUIReady
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) FileDropSend(ctx context.Context, in *FileDropSendRequest, opts ...grpc.CallOption) (*FileDropSendResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FileDropSendResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_FileDropSend_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) FileDropDecide(ctx context.Context, in *FileDropDecideRequest, opts ...grpc.CallOption) (*FileDropDecideResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FileDropDecideResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_FileDropDecide_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) FileDropCancel(ctx context.Context, in *FileDropCancelRequest, opts ...grpc.CallOption) (*FileDropCancelResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FileDropCancelResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_FileDropCancel_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) FileDropListTransfers(ctx context.Context, in *FileDropListTransfersRequest, opts ...grpc.CallOption) (*FileDropListTransfersResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FileDropListTransfersResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_FileDropListTransfers_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) FileDropDeleteTransfer(ctx context.Context, in *FileDropDeleteTransferRequest, opts ...grpc.CallOption) (*FileDropDeleteTransferResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FileDropDeleteTransferResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_FileDropDeleteTransfer_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) FileDropGetSettings(ctx context.Context, in *FileDropGetSettingsRequest, opts ...grpc.CallOption) (*FileDropGetSettingsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FileDropGetSettingsResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_FileDropGetSettings_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) FileDropSetSettings(ctx context.Context, in *FileDropSetSettingsRequest, opts ...grpc.CallOption) (*FileDropSetSettingsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FileDropSetSettingsResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_FileDropSetSettings_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) FileDropSetPeerRule(ctx context.Context, in *FileDropSetPeerRuleRequest, opts ...grpc.CallOption) (*FileDropSetPeerRuleResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FileDropSetPeerRuleResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_FileDropSetPeerRule_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DaemonServiceServer is the server API for DaemonService service.
|
||||
// All implementations must embed UnimplementedDaemonServiceServer
|
||||
// for forward compatibility.
|
||||
@@ -781,6 +887,24 @@ type DaemonServiceServer interface {
|
||||
// only cares whether the daemon implements it: an Unimplemented response
|
||||
// means the daemon predates this UI and is too old to drive it.
|
||||
WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error)
|
||||
// FileDropSend starts an asynchronous file transfer to a peer. Progress is
|
||||
// polled via FileDropListTransfers; the outcome also arrives as a SystemEvent.
|
||||
FileDropSend(context.Context, *FileDropSendRequest) (*FileDropSendResponse, error)
|
||||
// FileDropDecide accepts or declines a pending incoming offer.
|
||||
FileDropDecide(context.Context, *FileDropDecideRequest) (*FileDropDecideResponse, error)
|
||||
// FileDropCancel aborts a transfer in either direction.
|
||||
FileDropCancel(context.Context, *FileDropCancelRequest) (*FileDropCancelResponse, error)
|
||||
// FileDropListTransfers returns the transfer history, newest first, with live
|
||||
// progress for running transfers.
|
||||
FileDropListTransfers(context.Context, *FileDropListTransfersRequest) (*FileDropListTransfersResponse, error)
|
||||
// FileDropDeleteTransfer removes one entry from the transfer history.
|
||||
FileDropDeleteTransfer(context.Context, *FileDropDeleteTransferRequest) (*FileDropDeleteTransferResponse, error)
|
||||
// FileDropGetSettings returns the active profile's receiving policy.
|
||||
FileDropGetSettings(context.Context, *FileDropGetSettingsRequest) (*FileDropGetSettingsResponse, error)
|
||||
// FileDropSetSettings updates the active profile's receiving policy.
|
||||
FileDropSetSettings(context.Context, *FileDropSetSettingsRequest) (*FileDropSetSettingsResponse, error)
|
||||
// FileDropSetPeerRule sets or clears a per-sender exception.
|
||||
FileDropSetPeerRule(context.Context, *FileDropSetPeerRuleRequest) (*FileDropSetPeerRuleResponse, error)
|
||||
mustEmbedUnimplementedDaemonServiceServer()
|
||||
}
|
||||
|
||||
@@ -929,6 +1053,30 @@ func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grp
|
||||
func (UnimplementedDaemonServiceServer) WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method WailsUIReady not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) FileDropSend(context.Context, *FileDropSendRequest) (*FileDropSendResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method FileDropSend not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) FileDropDecide(context.Context, *FileDropDecideRequest) (*FileDropDecideResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method FileDropDecide not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) FileDropCancel(context.Context, *FileDropCancelRequest) (*FileDropCancelResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method FileDropCancel not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) FileDropListTransfers(context.Context, *FileDropListTransfersRequest) (*FileDropListTransfersResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method FileDropListTransfers not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) FileDropDeleteTransfer(context.Context, *FileDropDeleteTransferRequest) (*FileDropDeleteTransferResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method FileDropDeleteTransfer not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) FileDropGetSettings(context.Context, *FileDropGetSettingsRequest) (*FileDropGetSettingsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method FileDropGetSettings not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) FileDropSetSettings(context.Context, *FileDropSetSettingsRequest) (*FileDropSetSettingsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method FileDropSetSettings not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) FileDropSetPeerRule(context.Context, *FileDropSetPeerRuleRequest) (*FileDropSetPeerRuleResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method FileDropSetPeerRule not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {}
|
||||
func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@@ -1750,6 +1898,150 @@ func _DaemonService_WailsUIReady_Handler(srv interface{}, ctx context.Context, d
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_FileDropSend_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FileDropSendRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).FileDropSend(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_FileDropSend_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).FileDropSend(ctx, req.(*FileDropSendRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_FileDropDecide_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FileDropDecideRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).FileDropDecide(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_FileDropDecide_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).FileDropDecide(ctx, req.(*FileDropDecideRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_FileDropCancel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FileDropCancelRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).FileDropCancel(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_FileDropCancel_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).FileDropCancel(ctx, req.(*FileDropCancelRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_FileDropListTransfers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FileDropListTransfersRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).FileDropListTransfers(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_FileDropListTransfers_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).FileDropListTransfers(ctx, req.(*FileDropListTransfersRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_FileDropDeleteTransfer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FileDropDeleteTransferRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).FileDropDeleteTransfer(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_FileDropDeleteTransfer_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).FileDropDeleteTransfer(ctx, req.(*FileDropDeleteTransferRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_FileDropGetSettings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FileDropGetSettingsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).FileDropGetSettings(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_FileDropGetSettings_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).FileDropGetSettings(ctx, req.(*FileDropGetSettingsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_FileDropSetSettings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FileDropSetSettingsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).FileDropSetSettings(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_FileDropSetSettings_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).FileDropSetSettings(ctx, req.(*FileDropSetSettingsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_FileDropSetPeerRule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FileDropSetPeerRuleRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).FileDropSetPeerRule(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_FileDropSetPeerRule_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).FileDropSetPeerRule(ctx, req.(*FileDropSetPeerRuleRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -1925,6 +2217,38 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "WailsUIReady",
|
||||
Handler: _DaemonService_WailsUIReady_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FileDropSend",
|
||||
Handler: _DaemonService_FileDropSend_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FileDropDecide",
|
||||
Handler: _DaemonService_FileDropDecide_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FileDropCancel",
|
||||
Handler: _DaemonService_FileDropCancel_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FileDropListTransfers",
|
||||
Handler: _DaemonService_FileDropListTransfers_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FileDropDeleteTransfer",
|
||||
Handler: _DaemonService_FileDropDeleteTransfer_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FileDropGetSettings",
|
||||
Handler: _DaemonService_FileDropGetSettings_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FileDropSetSettings",
|
||||
Handler: _DaemonService_FileDropSetSettings_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FileDropSetPeerRule",
|
||||
Handler: _DaemonService_FileDropSetPeerRule_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
|
||||
@@ -59,3 +59,24 @@ const (
|
||||
// MetadataSourceMDM marks a config_changed driven by an MDM policy diff.
|
||||
MetadataSourceMDM = "mdm"
|
||||
)
|
||||
|
||||
// SystemEvent metadata markers for file drop transfers. The daemon publishes one
|
||||
// per transfer milestone; the UI shows a notification and refreshes its transfer
|
||||
// views. Progress is not evented — the UI polls FileDropListTransfers.
|
||||
const (
|
||||
// MetadataKindFileDropOffer marks an incoming offer awaiting the user's decision.
|
||||
MetadataKindFileDropOffer = "filedrop-offer"
|
||||
// MetadataKindFileDropCompleted marks a finished transfer in either direction.
|
||||
MetadataKindFileDropCompleted = "filedrop-completed"
|
||||
// MetadataKindFileDropFailed marks a transfer that ended without completing.
|
||||
MetadataKindFileDropFailed = "filedrop-failed"
|
||||
// MetadataKindFileDropWithdrawn marks a pending offer that was cancelled by the
|
||||
// sender or expired, so its consent prompt should be dismissed.
|
||||
MetadataKindFileDropWithdrawn = "filedrop-withdrawn"
|
||||
|
||||
// MetadataFileDropTransferKey carries the transfer ID for the filedrop-* kinds.
|
||||
MetadataFileDropTransferKey = "filedropTransferId"
|
||||
// MetadataFileDropPeerKey carries the remote peer's public key for the
|
||||
// filedrop-* kinds, so notification actions can set per-sender rules.
|
||||
MetadataFileDropPeerKey = "filedropPeerKey"
|
||||
)
|
||||
|
||||
412
client/server/filedrop.go
Normal file
412
client/server/filedrop.go
Normal file
@@ -0,0 +1,412 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
func (s *Server) fileDropManager() (*filedrop.Manager, error) {
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
if !profilemanager.IsValidProfileFilenameStem(activeProf.ID) {
|
||||
return nil, fmt.Errorf("invalid profile ID %q", activeProf.ID)
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
if s.fileDrop != nil && s.fileDrop.Profile() == activeProf.ID {
|
||||
mgr := s.fileDrop
|
||||
s.mutex.Unlock()
|
||||
return mgr, nil
|
||||
}
|
||||
old := s.fileDrop
|
||||
s.fileDrop = nil
|
||||
s.mutex.Unlock()
|
||||
|
||||
if old != nil {
|
||||
if err := old.Close(); err != nil {
|
||||
log.Warnf("failed to close previous file drop manager: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
prefs, err := s.profileManager.ProfilePrefs(activeProf.ID, activeProf.Username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve profile prefs: %w", err)
|
||||
}
|
||||
|
||||
mgr, err := filedrop.NewManager(filedrop.ManagerConfig{
|
||||
Profile: activeProf.ID,
|
||||
DataDir: filepath.Join(profilemanager.DefaultConfigPathDir, "filedrop", activeProf.ID.String()),
|
||||
Store: filedrop.NewProfileStore(prefs),
|
||||
Events: s.publishFileDropEvent,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create file drop manager: %w", err)
|
||||
}
|
||||
|
||||
seedFileDropDestination(mgr, activeProf.Username)
|
||||
|
||||
s.mutex.Lock()
|
||||
if s.fileDrop != nil && s.fileDrop.Profile() == activeProf.ID {
|
||||
winner := s.fileDrop
|
||||
s.mutex.Unlock()
|
||||
_ = mgr.Close()
|
||||
return winner, nil
|
||||
}
|
||||
s.fileDrop = mgr
|
||||
s.mutex.Unlock()
|
||||
return mgr, nil
|
||||
}
|
||||
|
||||
func (s *Server) publishFileDropEvent(kind filedrop.EventKind, transfer filedrop.Transfer) {
|
||||
s.mutex.Lock()
|
||||
statusRecorder := s.statusRecorder
|
||||
s.mutex.Unlock()
|
||||
if statusRecorder == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var metaKind, userMsg string
|
||||
switch kind {
|
||||
case filedrop.EventOffer:
|
||||
metaKind = proto.MetadataKindFileDropOffer
|
||||
userMsg = fmt.Sprintf("%s wants to send you %s", transfer.PeerName, transferLabel(transfer))
|
||||
case filedrop.EventCompleted:
|
||||
metaKind = proto.MetadataKindFileDropCompleted
|
||||
if transfer.Direction == filedrop.DirectionSent {
|
||||
userMsg = fmt.Sprintf("Sent %s to %s", transferLabel(transfer), transfer.PeerName)
|
||||
} else {
|
||||
userMsg = fmt.Sprintf("Received %s from %s", transferLabel(transfer), transfer.PeerName)
|
||||
}
|
||||
case filedrop.EventFailed:
|
||||
metaKind = proto.MetadataKindFileDropFailed
|
||||
userMsg = fmt.Sprintf("Transfer of %s failed", transferLabel(transfer))
|
||||
case filedrop.EventWithdrawn:
|
||||
metaKind = proto.MetadataKindFileDropWithdrawn
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
statusRecorder.PublishEvent(
|
||||
proto.SystemEvent_INFO,
|
||||
proto.SystemEvent_SYSTEM,
|
||||
"file drop transfer update",
|
||||
userMsg,
|
||||
map[string]string{
|
||||
proto.MetadataKindKey: metaKind,
|
||||
proto.MetadataFileDropTransferKey: string(transfer.ID),
|
||||
proto.MetadataFileDropPeerKey: string(transfer.PeerKey),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// FileDropSend starts an asynchronous transfer to a peer.
|
||||
func (s *Server) FileDropSend(ctx context.Context, req *proto.FileDropSendRequest) (*proto.FileDropSendResponse, error) {
|
||||
mgr, err := s.fileDropManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
statusRecorder := s.statusRecorder
|
||||
s.mutex.Unlock()
|
||||
if statusRecorder == nil {
|
||||
return nil, errors.New("not connected")
|
||||
}
|
||||
|
||||
peerState, err := statusRecorder.GetPeer(req.GetPeerKey())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unknown peer: %w", err)
|
||||
}
|
||||
addr, err := netip.ParseAddr(peerState.IP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse peer address: %w", err)
|
||||
}
|
||||
|
||||
payloads, err := s.buildFileDropPayloads(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := mgr.Send(filedrop.PeerKey(req.GetPeerKey()), peerState.FQDN, addr.Unmap(), payloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.FileDropSendResponse{TransferId: string(id)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) buildFileDropPayloads(ctx context.Context, req *proto.FileDropSendRequest) ([]filedrop.Payload, error) {
|
||||
var payloads []filedrop.Payload
|
||||
|
||||
if len(req.GetPaths()) > 0 {
|
||||
caller, ok := ipcauth.CallerIdentity(ctx)
|
||||
if !ok {
|
||||
return nil, errors.New("caller identity required to send files")
|
||||
}
|
||||
for _, path := range req.GetPaths() {
|
||||
payload, err := fileDropPayload(caller, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payloads = append(payloads, payload)
|
||||
}
|
||||
}
|
||||
|
||||
if text := req.GetText(); text != "" {
|
||||
if len(text) > filedrop.MaxInlineTextSize {
|
||||
return nil, fmt.Errorf("text exceeds %d bytes", filedrop.MaxInlineTextSize)
|
||||
}
|
||||
payloads = append(payloads, filedrop.TextPayload("text", text))
|
||||
}
|
||||
|
||||
if len(payloads) == 0 {
|
||||
return nil, errors.New("nothing to send")
|
||||
}
|
||||
return payloads, nil
|
||||
}
|
||||
|
||||
// FileDropDecide accepts or declines a pending incoming offer.
|
||||
func (s *Server) FileDropDecide(_ context.Context, req *proto.FileDropDecideRequest) (*proto.FileDropDecideResponse, error) {
|
||||
mgr, err := s.fileDropManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id := filedrop.OfferID(req.GetTransferId())
|
||||
if req.GetAccept() {
|
||||
err = mgr.Accept(id)
|
||||
} else {
|
||||
err = mgr.Decline(id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.FileDropDecideResponse{}, nil
|
||||
}
|
||||
|
||||
// FileDropCancel aborts a transfer in either direction.
|
||||
func (s *Server) FileDropCancel(_ context.Context, req *proto.FileDropCancelRequest) (*proto.FileDropCancelResponse, error) {
|
||||
mgr, err := s.fileDropManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mgr.Cancel(filedrop.OfferID(req.GetTransferId()))
|
||||
return &proto.FileDropCancelResponse{}, nil
|
||||
}
|
||||
|
||||
// FileDropListTransfers returns the transfer history, newest first.
|
||||
func (s *Server) FileDropListTransfers(context.Context, *proto.FileDropListTransfersRequest) (*proto.FileDropListTransfersResponse, error) {
|
||||
mgr, err := s.fileDropManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transfers := mgr.Transfers()
|
||||
resp := &proto.FileDropListTransfersResponse{
|
||||
Transfers: make([]*proto.FileDropTransfer, 0, len(transfers)),
|
||||
}
|
||||
for _, t := range transfers {
|
||||
resp.Transfers = append(resp.Transfers, fileDropTransferToProto(t))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// FileDropDeleteTransfer removes one entry from the transfer history.
|
||||
func (s *Server) FileDropDeleteTransfer(_ context.Context, req *proto.FileDropDeleteTransferRequest) (*proto.FileDropDeleteTransferResponse, error) {
|
||||
mgr, err := s.fileDropManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mgr.DeleteTransfer(filedrop.OfferID(req.GetTransferId()))
|
||||
return &proto.FileDropDeleteTransferResponse{}, nil
|
||||
}
|
||||
|
||||
// FileDropGetSettings returns the active profile's receiving policy.
|
||||
func (s *Server) FileDropGetSettings(context.Context, *proto.FileDropGetSettingsRequest) (*proto.FileDropGetSettingsResponse, error) {
|
||||
mgr, err := s.fileDropManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
policy := mgr.Policy().Get()
|
||||
rules := make(map[string]proto.FileDropRule, len(policy.Senders))
|
||||
for key, rule := range policy.Senders {
|
||||
rules[string(key)] = proto.FileDropRule(rule)
|
||||
}
|
||||
|
||||
return &proto.FileDropGetSettingsResponse{
|
||||
Mode: proto.FileDropMode(policy.Mode),
|
||||
DestinationDir: mgr.DestinationDir(),
|
||||
PeerRules: rules,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FileDropSetSettings updates the active profile's receiving policy.
|
||||
func (s *Server) FileDropSetSettings(ctx context.Context, req *proto.FileDropSetSettingsRequest) (*proto.FileDropSetSettingsResponse, error) {
|
||||
mgr, err := s.fileDropManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
policy := mgr.Policy().Get()
|
||||
policy.Mode = filedrop.Mode(req.GetMode())
|
||||
if err := mgr.Policy().Set(policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dir := req.GetDestinationDir(); dir != mgr.DestinationDir() {
|
||||
if err := s.validateFileDropDestination(ctx, dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mgr.SetDestinationDir(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &proto.FileDropSetSettingsResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) validateFileDropDestination(ctx context.Context, dir string) error {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
if !filepath.IsAbs(dir) {
|
||||
return errors.New("destination must be an absolute path")
|
||||
}
|
||||
|
||||
caller, ok := ipcauth.CallerIdentity(ctx)
|
||||
if !ok {
|
||||
return errors.New("caller identity required to change the destination")
|
||||
}
|
||||
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("destination: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return errors.New("destination is not a directory")
|
||||
}
|
||||
return checkDirOwnership(caller, dir, info)
|
||||
}
|
||||
|
||||
// FileDropSetPeerRule sets or clears a per-sender exception.
|
||||
func (s *Server) FileDropSetPeerRule(_ context.Context, req *proto.FileDropSetPeerRuleRequest) (*proto.FileDropSetPeerRuleResponse, error) {
|
||||
mgr, err := s.fileDropManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mgr.SetSenderRule(filedrop.PeerKey(req.GetPeerKey()), filedrop.SenderRule(req.GetRule())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.FileDropSetPeerRuleResponse{}, nil
|
||||
}
|
||||
|
||||
func fileDropTransferToProto(t filedrop.Transfer) *proto.FileDropTransfer {
|
||||
files := make([]*proto.FileDropFile, 0, len(t.Files))
|
||||
for _, f := range t.Files {
|
||||
files = append(files, &proto.FileDropFile{
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
ContentType: f.ContentType,
|
||||
IsText: f.Kind == filedrop.KindText,
|
||||
Text: f.Text,
|
||||
})
|
||||
}
|
||||
|
||||
return &proto.FileDropTransfer{
|
||||
Id: string(t.ID),
|
||||
Outgoing: t.Direction == filedrop.DirectionSent,
|
||||
PeerKey: string(t.PeerKey),
|
||||
PeerName: t.PeerName,
|
||||
Files: files,
|
||||
State: proto.FileDropState(t.State),
|
||||
Transferred: t.Transferred,
|
||||
TotalSize: t.TotalSize,
|
||||
CreatedAt: timestamppb.New(t.CreatedAt),
|
||||
UpdatedAt: timestamppb.New(t.UpdatedAt),
|
||||
DeliveredPaths: t.DeliveredPaths,
|
||||
Error: t.Error,
|
||||
Reason: proto.FileDropReason(t.Reason),
|
||||
}
|
||||
}
|
||||
|
||||
func transferLabel(t filedrop.Transfer) string {
|
||||
if len(t.Files) == 1 {
|
||||
return t.Files[0].Name
|
||||
}
|
||||
return fmt.Sprintf("%d files", len(t.Files))
|
||||
}
|
||||
|
||||
func fileDropPayload(caller ipcauth.Identity, path string) (filedrop.Payload, error) {
|
||||
f, err := ipcauth.OpenOwnedFile(caller, path)
|
||||
if err != nil {
|
||||
return filedrop.Payload{}, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
info, err := f.Stat()
|
||||
closeErr := f.Close()
|
||||
if err != nil {
|
||||
return filedrop.Payload{}, fmt.Errorf("stat %s: %w", path, err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return filedrop.Payload{}, fmt.Errorf("close %s: %w", path, closeErr)
|
||||
}
|
||||
|
||||
return filedrop.Payload{
|
||||
Meta: filedrop.FileMeta{
|
||||
Name: filepath.Base(path),
|
||||
Size: info.Size(),
|
||||
ContentType: mime.TypeByExtension(filepath.Ext(path)),
|
||||
},
|
||||
Open: func(offset int64) (io.ReadCloser, error) {
|
||||
f, err := ipcauth.OpenOwnedFile(caller, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// seedFileDropDestination gives a profile a delivery directory on first use, so
|
||||
// a received file always has somewhere to land. Resolved from the profile's own
|
||||
// user rather than the process: the daemon runs as root, whose home is not where
|
||||
// the user would look for their downloads.
|
||||
func seedFileDropDestination(mgr *filedrop.Manager, username string) {
|
||||
if mgr.DestinationDir() != "" {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
log.Warnf("cannot resolve home of %s for the file drop destination: %v", username, err)
|
||||
return
|
||||
}
|
||||
if u.HomeDir == "" {
|
||||
log.Warnf("user %s has no home directory for the file drop destination", username)
|
||||
return
|
||||
}
|
||||
|
||||
dir := filepath.Join(u.HomeDir, "Downloads", "NetBird")
|
||||
if err := mgr.SetDestinationDir(dir); err != nil {
|
||||
log.Warnf("failed to set the default file drop destination: %v", err)
|
||||
}
|
||||
}
|
||||
25
client/server/filedrop_unix.go
Normal file
25
client/server/filedrop_unix.go
Normal file
@@ -0,0 +1,25 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
func checkDirOwnership(caller ipcauth.Identity, _ string, info os.FileInfo) error {
|
||||
if caller.UID == 0 {
|
||||
return nil
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return errors.New("cannot determine destination ownership")
|
||||
}
|
||||
if stat.Uid != caller.UID {
|
||||
return errors.New("destination is not owned by the caller")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
11
client/server/filedrop_windows.go
Normal file
11
client/server/filedrop_windows.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
func checkDirOwnership(ipcauth.Identity, string, os.FileInfo) error {
|
||||
return nil
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
@@ -109,6 +110,8 @@ type Server struct {
|
||||
statusRecorder *peer.Status
|
||||
sessionWatcher *internal.SessionWatcher
|
||||
|
||||
fileDrop *filedrop.Manager
|
||||
|
||||
probeThrottle *probeThrottle
|
||||
persistSyncResponse bool
|
||||
isSessionActive atomic.Bool
|
||||
@@ -2351,6 +2354,12 @@ func (s *Server) connect(ctx context.Context, config *profilemanager.Config, sta
|
||||
client.SetUpdateManager(s.updateManager)
|
||||
client.SetSyncResponsePersistence(s.persistSyncResponse)
|
||||
|
||||
if mgr, err := s.fileDropManager(); err != nil {
|
||||
log.Warnf("file drop is unavailable: %v", err)
|
||||
} else {
|
||||
client.SetFileDropManager(mgr)
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
s.connectClient = client
|
||||
s.mutex.Unlock()
|
||||
|
||||
@@ -313,21 +313,23 @@ 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)
|
||||
}
|
||||
|
||||
clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
||||
client, err := nbssh.Handshake(ctx, conn, addr, config)
|
||||
if err != nil {
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
log.Debugf("connection close after handshake failure: %v", closeErr)
|
||||
}
|
||||
return nil, fmt.Errorf("ssh handshake: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := ssh.NewClient(clientConn, chans, reqs)
|
||||
return &Client{
|
||||
client: client,
|
||||
}, nil
|
||||
|
||||
@@ -12,6 +12,8 @@ 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 {
|
||||
@@ -82,37 +84,7 @@ func (c *Client) setupTerminal(session *ssh.Session, fd int) error {
|
||||
return fmt.Errorf("get terminal size: %w", err)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
modes := nbssh.DefaultTerminalModes
|
||||
|
||||
terminal := os.Getenv("TERM")
|
||||
if terminal == "" {
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -80,28 +82,14 @@ func (c *Client) setupTerminalMode(_ context.Context, session *ssh.Session) erro
|
||||
w, h := c.getWindowsConsoleSize()
|
||||
|
||||
modes := ssh.TerminalModes{
|
||||
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
|
||||
ssh.ICRNL: 1,
|
||||
ssh.OPOST: 1,
|
||||
ssh.ONLCR: 1,
|
||||
ssh.ISIG: 1,
|
||||
ssh.ICANON: 1,
|
||||
}
|
||||
for mode, value := range nbssh.DefaultTerminalModes {
|
||||
modes[mode] = value
|
||||
}
|
||||
|
||||
if err := session.RequestPty("xterm-256color", h, w, modes); err != nil {
|
||||
|
||||
@@ -35,6 +35,19 @@ 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
|
||||
|
||||
45
client/ssh/handshake.go
Normal file
45
client/ssh/handshake.go
Normal file
@@ -0,0 +1,45 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -610,13 +610,10 @@ func (p *SSHProxy) dialBackend(ctx context.Context, addr, user, jwtToken string)
|
||||
return nil, fmt.Errorf("connect to server: %w", err)
|
||||
}
|
||||
|
||||
clientConn, chans, reqs, err := cryptossh.NewClientConn(conn, addr, config)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("SSH handshake: %w", err)
|
||||
}
|
||||
handshakeCtx, cancel := context.WithTimeout(ctx, sshHandshakeTimeout)
|
||||
defer cancel()
|
||||
|
||||
return cryptossh.NewClient(clientConn, chans, reqs), nil
|
||||
return nbssh.Handshake(handshakeCtx, conn, addr, config)
|
||||
}
|
||||
|
||||
func (p *SSHProxy) verifyHostKey(hostname string, remote net.Addr, key cryptossh.PublicKey) error {
|
||||
|
||||
84
client/ssh/session.go
Normal file
84
client/ssh/session.go
Normal file
@@ -0,0 +1,84 @@
|
||||
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
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
export type NavSection = "peers" | "networks";
|
||||
export type NavSection = "peers" | "networks" | "files";
|
||||
|
||||
type NavSectionContextValue = {
|
||||
section: NavSection;
|
||||
|
||||
47
client/ui/frontend/src/lib/filedrop.ts
Normal file
47
client/ui/frontend/src/lib/filedrop.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export const enum TransferState {
|
||||
Pending = 0,
|
||||
Transferring = 1,
|
||||
Completed = 2,
|
||||
Declined = 3,
|
||||
Expired = 4,
|
||||
Cancelled = 5,
|
||||
Failed = 6,
|
||||
}
|
||||
|
||||
export const enum ReceiveMode {
|
||||
Off = 0,
|
||||
Ask = 1,
|
||||
Auto = 2,
|
||||
}
|
||||
|
||||
export const enum PeerRule {
|
||||
Default = 0,
|
||||
Always = 1,
|
||||
Block = 2,
|
||||
}
|
||||
|
||||
export const enum FailureReason {
|
||||
None = 0,
|
||||
Unreachable = 1,
|
||||
}
|
||||
|
||||
export const isTerminalState = (state: number): boolean => state >= TransferState.Completed;
|
||||
|
||||
export const stateLabelKey = (state: number): string => {
|
||||
switch (state) {
|
||||
case TransferState.Pending:
|
||||
return "files.state.pending";
|
||||
case TransferState.Transferring:
|
||||
return "files.state.transferring";
|
||||
case TransferState.Completed:
|
||||
return "files.state.received";
|
||||
case TransferState.Declined:
|
||||
return "files.state.declined";
|
||||
case TransferState.Expired:
|
||||
return "files.state.expired";
|
||||
case TransferState.Cancelled:
|
||||
return "files.state.cancelled";
|
||||
default:
|
||||
return "files.state.failed";
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { Peers } from "@/modules/main/advanced/peers/Peers";
|
||||
import { Networks } from "@/modules/main/advanced/networks/Networks";
|
||||
import { Files } from "@/modules/main/advanced/files/Files";
|
||||
import { NetworksProvider } from "@/contexts/NetworksContext";
|
||||
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext";
|
||||
@@ -44,7 +45,10 @@ const MainBody = () => {
|
||||
const isAdvanced = viewMode === "advanced";
|
||||
|
||||
return (
|
||||
<main className={"wails-draggable flex min-h-0 flex-1"}>
|
||||
// min-w-0 matters here: without it this flex parent keeps its automatic
|
||||
// minimum width, and a long file name in the right panel pushes the
|
||||
// layout wider than the window, which cannot be resized.
|
||||
<main className={"wails-draggable flex min-h-0 min-w-0 flex-1"}>
|
||||
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
|
||||
See https://github.com/wailsapp/wails/issues/3260 */}
|
||||
<div
|
||||
@@ -98,10 +102,14 @@ const AdvancedAppRightPanel = () => {
|
||||
role={"tabpanel"}
|
||||
id={`nb-tabpanel-${section}`}
|
||||
aria-labelledby={`nb-tab-${section}`}
|
||||
className={"flex min-h-0 flex-1 flex-col"}
|
||||
// min-w-0: this is the section's direct parent, so without
|
||||
// it a long file name sets the floor for the whole panel
|
||||
// and the row overflows instead of truncating.
|
||||
className={"flex min-h-0 min-w-0 flex-1 flex-col"}
|
||||
>
|
||||
{section === "peers" && <Peers />}
|
||||
{section === "networks" && <Networks />}
|
||||
{section === "files" && <Files />}
|
||||
</div>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ComponentType, type KeyboardEvent, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { FolderDownIcon, Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
@@ -40,6 +40,11 @@ export const Navigation = () => {
|
||||
icon: Layers3Icon,
|
||||
});
|
||||
}
|
||||
tabs.push({
|
||||
value: "files",
|
||||
label: t("nav.files.title"),
|
||||
icon: FolderDownIcon,
|
||||
});
|
||||
|
||||
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
@@ -103,7 +108,7 @@ export const Navigation = () => {
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"group relative flex flex-1 items-center justify-center",
|
||||
"group relative flex min-w-0 flex-1 items-center justify-center",
|
||||
"gap-2.5 px-5 py-3.5",
|
||||
"outline-none transition-all",
|
||||
isFirst && "rounded-tl-xl",
|
||||
@@ -113,8 +118,8 @@ export const Navigation = () => {
|
||||
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<Icon size={14} aria-hidden={"true"} />
|
||||
<span className={"text-sm font-normal"}>{tab.label}</span>
|
||||
<Icon size={14} className={"shrink-0"} aria-hidden={"true"} />
|
||||
<span className={"truncate text-sm font-normal"}>{tab.label}</span>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
|
||||
494
client/ui/frontend/src/modules/main/advanced/files/Files.tsx
Normal file
494
client/ui/frontend/src/modules/main/advanced/files/Files.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
BanIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
FolderDownIcon,
|
||||
FolderOpenIcon,
|
||||
MoreVerticalIcon,
|
||||
ShieldCheckIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { FileDrop } from "@bindings/services";
|
||||
import type { FileDropTransfer } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatBytes } from "@/lib/formatters";
|
||||
import {
|
||||
FailureReason,
|
||||
isTerminalState,
|
||||
PeerRule,
|
||||
stateLabelKey,
|
||||
TransferState,
|
||||
} from "@/lib/filedrop";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
|
||||
const EVENT_FILEDROP = "netbird:filedrop";
|
||||
const POLL_MS = 1000;
|
||||
|
||||
const transferTitle = (transfer: FileDropTransfer): string => {
|
||||
const files = transfer.files ?? [];
|
||||
if (files.length === 1 && files[0].isText) {
|
||||
return `“${files[0].text}”`;
|
||||
}
|
||||
return files.map((f) => f.name).join(", ");
|
||||
};
|
||||
|
||||
const isTextTransfer = (transfer: FileDropTransfer): boolean =>
|
||||
(transfer.files ?? []).length === 1 && transfer.files[0].isText;
|
||||
|
||||
const timeLabel = (iso: string): string => {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
|
||||
// Colours only what the eye should catch scanning the outcome column: a refusal
|
||||
// or failure in red, a completed send in green. Everything else, a received file
|
||||
// included, stays neutral so the exceptions stand out.
|
||||
const outcomeClass = (transfer: FileDropTransfer, inProgress: boolean): string => {
|
||||
if (inProgress) return "text-nb-gray-500";
|
||||
switch (transfer.state) {
|
||||
case TransferState.Declined:
|
||||
case TransferState.Failed:
|
||||
return "text-red-400";
|
||||
case TransferState.Completed:
|
||||
return transfer.outgoing ? "text-green-400" : "text-nb-gray-500";
|
||||
default:
|
||||
return "text-nb-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
export const Files = () => {
|
||||
const { t } = useTranslation();
|
||||
const [transfers, setTransfers] = useState<FileDropTransfer[] | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setTransfers(await FileDrop.List());
|
||||
} catch {
|
||||
setTransfers((prev) => prev ?? []);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
void refresh();
|
||||
const id = setInterval(() => void refresh(), POLL_MS);
|
||||
const off = Events.On(EVENT_FILEDROP, () => void refresh());
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
off();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const all = transfers ?? [];
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter(
|
||||
(tr) =>
|
||||
transferTitle(tr).toLowerCase().includes(q) ||
|
||||
tr.peerName.toLowerCase().includes(q),
|
||||
);
|
||||
}, [transfers, search]);
|
||||
|
||||
const pending = filtered.filter((tr) => !tr.outgoing && tr.state === TransferState.Pending);
|
||||
const rest = filtered.filter((tr) => tr.outgoing || tr.state !== TransferState.Pending);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const byDay = new Map<string, FileDropTransfer[]>();
|
||||
for (const tr of rest) {
|
||||
const d = new Date(tr.createdAt as unknown as string);
|
||||
const key = Number.isNaN(d.getTime()) ? "" : d.toDateString();
|
||||
const list = byDay.get(key) ?? [];
|
||||
list.push(tr);
|
||||
byDay.set(key, list);
|
||||
}
|
||||
const today = new Date().toDateString();
|
||||
const yesterday = new Date(Date.now() - 86400000).toDateString();
|
||||
return Array.from(byDay.entries()).map(([key, list]) => {
|
||||
let label = key;
|
||||
if (key === today) label = t("files.group.today");
|
||||
else if (key === yesterday) label = t("files.group.yesterday");
|
||||
else if (key) label = new Date(key).toLocaleDateString();
|
||||
return { label, list };
|
||||
});
|
||||
}, [rest, t]);
|
||||
|
||||
if (transfers !== null && transfers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={FolderDownIcon}
|
||||
title={t("files.empty.title")}
|
||||
description={t("files.empty.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"flex h-full min-h-0 w-full flex-col"}>
|
||||
<div className={"flex items-center gap-2 border-b border-nb-gray-910 px-6 py-2.5"}>
|
||||
<div className={"min-w-0 flex-1"}>
|
||||
<SearchInput
|
||||
ref={searchRef}
|
||||
placeholder={t("files.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<div className={"flex flex-col pb-4 pt-2"}>
|
||||
{pending.map((tr) => (
|
||||
<PendingOfferRow key={tr.id} transfer={tr} onChanged={refresh} />
|
||||
))}
|
||||
{groups.map((group) => (
|
||||
<div key={group.label} className={"flex flex-col"}>
|
||||
<div
|
||||
className={
|
||||
"px-6 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider text-nb-gray-500"
|
||||
}
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
{group.list.map((tr) => (
|
||||
<TransferRow
|
||||
key={tr.id}
|
||||
transfer={tr}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={"flex w-1.5 touch-none select-none bg-transparent py-1"}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type RowProps = {
|
||||
transfer: FileDropTransfer;
|
||||
onChanged: () => void;
|
||||
};
|
||||
|
||||
const PendingOfferRow = ({ transfer, onChanged }: RowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const decide = async (accept: boolean) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await FileDrop.Decide(transfer.id, accept);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
onChanged();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// The panel is a fixed ~500px wide, so the buttons sit under the text
|
||||
// rather than beside it: side by side they would squeeze the file name
|
||||
// down to a few characters.
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 mt-2 flex flex-col gap-2.5 rounded-lg border border-netbird/30",
|
||||
"bg-netbird/5 px-4 py-3",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
<div className={"flex min-w-0 items-center gap-2.5"}>
|
||||
<ArrowDownIcon
|
||||
size={16}
|
||||
className={"shrink-0 text-netbird"}
|
||||
aria-hidden={"true"}
|
||||
/>
|
||||
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
|
||||
<span className={"truncate text-[0.81rem] font-medium text-nb-gray-100"}>
|
||||
{transferTitle(transfer)}
|
||||
<span className={"font-normal text-nb-gray-400"}>
|
||||
{" · "}
|
||||
{formatBytes(transfer.totalSize)}
|
||||
</span>
|
||||
</span>
|
||||
<span className={"truncate text-xs text-nb-gray-400"}>
|
||||
{t("files.offer.subtitle", { peer: transfer.peerName })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex items-center gap-2 pl-[26px]"}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"xs"}
|
||||
disabled={busy}
|
||||
onClick={() => void decide(true)}
|
||||
>
|
||||
{t("files.offer.accept")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
disabled={busy}
|
||||
onClick={() => void decide(false)}
|
||||
>
|
||||
{t("files.offer.decline")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TransferRow = ({ transfer, onChanged }: RowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isText = isTextTransfer(transfer);
|
||||
const live = !isTerminalState(transfer.state);
|
||||
const delivered =
|
||||
!transfer.outgoing &&
|
||||
transfer.state === TransferState.Completed &&
|
||||
(transfer.deliveredPaths ?? []).length > 0;
|
||||
const progress =
|
||||
transfer.state === TransferState.Transferring && transfer.totalSize > 0
|
||||
? Math.min(100, Math.floor((transfer.transferred / transfer.totalSize) * 100))
|
||||
: null;
|
||||
|
||||
// The subtitle carries who and how big; the outcome sits on the right next
|
||||
// to the time, so a glance down that column reads the results.
|
||||
const subtitleParts = [
|
||||
transfer.outgoing
|
||||
? t("files.row.to", { peer: transfer.peerName })
|
||||
: t("files.row.from", { peer: transfer.peerName }),
|
||||
];
|
||||
if (!isText) subtitleParts.push(formatBytes(transfer.totalSize));
|
||||
|
||||
let stateLabel: string;
|
||||
if (progress !== null) {
|
||||
stateLabel = t("files.state.progress", { percent: progress });
|
||||
} else if (transfer.state === TransferState.Completed) {
|
||||
stateLabel = t(transfer.outgoing ? "files.state.sent" : "files.state.received");
|
||||
} else if (
|
||||
transfer.state === TransferState.Failed &&
|
||||
transfer.reason === FailureReason.Unreachable
|
||||
) {
|
||||
stateLabel = t("files.state.unreachable");
|
||||
} else {
|
||||
stateLabel = t(stateLabelKey(transfer.state));
|
||||
}
|
||||
|
||||
const stateClass = outcomeClass(transfer, progress !== null);
|
||||
const time = timeLabel(transfer.createdAt as unknown as string);
|
||||
|
||||
const copyText = async () => {
|
||||
await navigator.clipboard.writeText(transfer.files[0]?.text ?? "");
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex items-center gap-2.5 py-2.5 pl-6 pr-4",
|
||||
"transition-colors hover:bg-nb-gray-900/40",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
{transfer.outgoing ? (
|
||||
<ArrowUpIcon size={15} className={"shrink-0 text-netbird"} aria-hidden={"true"} />
|
||||
) : (
|
||||
<ArrowDownIcon
|
||||
size={15}
|
||||
className={"shrink-0 text-green-400"}
|
||||
aria-hidden={"true"}
|
||||
/>
|
||||
)}
|
||||
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-[0.81rem] font-medium text-nb-gray-100",
|
||||
isText && "italic",
|
||||
)}
|
||||
>
|
||||
{transferTitle(transfer)}
|
||||
</span>
|
||||
<span className={"truncate text-xs text-nb-gray-400"}>
|
||||
{subtitleParts.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
{/* Time over outcome, capped: the window is a fixed 900px and some
|
||||
translations of the state labels are long enough to eat the file
|
||||
name if they share one line with the timestamp. */}
|
||||
<span
|
||||
className={cn(
|
||||
"flex max-w-[8.5rem] shrink-0 flex-col items-end leading-tight",
|
||||
"pl-2 text-xs text-nb-gray-500",
|
||||
"transition-opacity group-hover:opacity-0",
|
||||
)}
|
||||
>
|
||||
<span className={"tabular-nums"}>{time}</span>
|
||||
<span className={cn("max-w-full truncate", stateClass)}>{stateLabel}</span>
|
||||
</span>
|
||||
{/* The window is a fixed 900px wide and cannot be resized, so the
|
||||
row actions overlay the outcome column on hover instead of
|
||||
reserving width that the file name would otherwise lose. */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-4 flex items-center gap-1",
|
||||
// Fades the covered outcome text out rather than sitting on
|
||||
// a flat block, which would not match the row's hover tint.
|
||||
"bg-gradient-to-l from-nb-gray-940 via-nb-gray-940 to-transparent pl-8",
|
||||
"opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
{isText && (
|
||||
<RowIconButton label={t("files.action.copy")} onClick={() => void copyText()}>
|
||||
{copied ? (
|
||||
<CheckIcon size={14} className={"text-green-400"} />
|
||||
) : (
|
||||
<CopyIcon size={14} />
|
||||
)}
|
||||
</RowIconButton>
|
||||
)}
|
||||
{delivered && (
|
||||
<RowIconButton
|
||||
label={t("files.action.reveal")}
|
||||
onClick={() => void FileDrop.Reveal(transfer.deliveredPaths[0])}
|
||||
>
|
||||
<FolderOpenIcon size={14} />
|
||||
</RowIconButton>
|
||||
)}
|
||||
{live && (
|
||||
<RowIconButton
|
||||
label={t("files.action.cancel")}
|
||||
onClick={async () => {
|
||||
await FileDrop.Cancel(transfer.id);
|
||||
onChanged();
|
||||
}}
|
||||
>
|
||||
<XIcon size={14} />
|
||||
</RowIconButton>
|
||||
)}
|
||||
<TransferMenu transfer={transfer} delivered={delivered} onChanged={onChanged} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RowIconButton = ({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Tooltip content={label}>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const TransferMenu = ({ transfer, delivered, onChanged }: RowProps & { delivered: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setRule = async (rule: PeerRule) => {
|
||||
await FileDrop.SetPeerRule(transfer.peerKey, rule);
|
||||
onChanged();
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={t("files.action.more")}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
)}
|
||||
>
|
||||
<MoreVerticalIcon size={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"}>
|
||||
{delivered && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => void FileDrop.Open(transfer.deliveredPaths[0])}
|
||||
>
|
||||
<FolderOpenIcon size={14} className={"mr-2"} />
|
||||
{t("files.action.open")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!transfer.outgoing && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => void setRule(PeerRule.Always)}>
|
||||
<ShieldCheckIcon size={14} className={"mr-2"} />
|
||||
{t("files.action.alwaysAccept")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => void setRule(PeerRule.Block)}>
|
||||
<BanIcon size={14} className={"mr-2"} />
|
||||
{t("files.action.block")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
variant={"danger"}
|
||||
onClick={async () => {
|
||||
await FileDrop.Delete(transfer.id);
|
||||
onChanged();
|
||||
}}
|
||||
>
|
||||
<Trash2Icon size={14} className={"mr-2"} />
|
||||
{t("files.action.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Check as CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronsLeftRightEllipsisIcon,
|
||||
ClipboardIcon,
|
||||
ClockIcon,
|
||||
Copy as CopyIcon,
|
||||
GaugeIcon,
|
||||
@@ -31,9 +32,13 @@ import {
|
||||
MonitorIcon,
|
||||
Radio,
|
||||
RefreshCwIcon,
|
||||
SendIcon,
|
||||
WaypointsIcon,
|
||||
} from "lucide-react";
|
||||
import { FileDrop } from "@bindings/services";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { useNavSection } from "@/contexts/NavSectionContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
@@ -250,6 +255,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
</div>
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<PeerSendActions peer={selected} />
|
||||
<PeerDetails peer={selected} now={now} />
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
@@ -272,6 +278,76 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const PeerSendActions = ({ peer }: { peer: PeerStatus }) => {
|
||||
const { t } = useTranslation();
|
||||
const { setSection } = useNavSection();
|
||||
const { setSelected } = usePeerDetail();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Deliberately not gated on connStatus: an idle peer is the normal resting
|
||||
// state under lazy connections, and the outgoing packets of the transfer are
|
||||
// exactly what wakes it. Only a peer without an overlay address has nothing
|
||||
// to dial.
|
||||
const canSend = peer.ip !== "";
|
||||
|
||||
const finishSend = () => {
|
||||
setSelected(null);
|
||||
setSection("files");
|
||||
};
|
||||
|
||||
const sendFiles = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const paths = await FileDrop.PickFiles();
|
||||
if (!paths || paths.length === 0) return;
|
||||
await FileDrop.Send(peer.pubKey, paths, "");
|
||||
finishSend();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const sendClipboard = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const text = await FileDrop.ClipboardText();
|
||||
if (!text) {
|
||||
setError(t("peers.details.sendClipboard.empty"));
|
||||
return;
|
||||
}
|
||||
await FileDrop.Send(peer.pubKey, [], text);
|
||||
finishSend();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"border-b border-nb-gray-920 px-5 py-3"}>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
disabled={!canSend}
|
||||
onClick={() => void sendFiles()}
|
||||
>
|
||||
<SendIcon size={12} aria-hidden={"true"} />
|
||||
{t("peers.details.sendFile")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
disabled={!canSend}
|
||||
onClick={() => void sendClipboard()}
|
||||
>
|
||||
<ClipboardIcon size={12} aria-hidden={"true"} />
|
||||
{t("peers.details.sendClipboard")}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <div className={"mt-2 text-xs text-red-400"}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
const { t } = useTranslation();
|
||||
const formatAge = (unix: number, fallback: string): string => {
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { type KeyboardEvent, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
type DragEvent,
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
||||
import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { ChevronRightIcon, MonitorSmartphoneIcon, SendIcon } from "lucide-react";
|
||||
import { FileDrop } from "@bindings/services";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { useNavSection } from "@/contexts/NavSectionContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { reconcileOrder } from "@/lib/sorting";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
@@ -41,13 +53,36 @@ export const peerStatusLabelKey = (connStatus: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const EVENT_FILES_DROPPED = "netbird:files:dropped";
|
||||
|
||||
export const Peers = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const { setSection } = useNavSection();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const dropTargetRef = useRef<string | null>(null);
|
||||
|
||||
const setDropTargetBoth = useCallback((pubKey: string | null) => {
|
||||
dropTargetRef.current = pubKey;
|
||||
setDropTarget(pubKey);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const off = Events.On(EVENT_FILES_DROPPED, (ev: { data: string[] | string[][] }) => {
|
||||
const target = dropTargetRef.current;
|
||||
setDropTargetBoth(null);
|
||||
if (!target) return;
|
||||
const raw = ev.data;
|
||||
const paths = (Array.isArray(raw[0]) ? raw[0] : raw) as string[];
|
||||
if (paths.length === 0) return;
|
||||
void FileDrop.Send(target, paths, "").then(() => setSection("files"));
|
||||
});
|
||||
return off;
|
||||
}, [setDropTargetBoth, setSection]);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
@@ -135,9 +170,26 @@ export const Peers = () => {
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Root
|
||||
type={"auto"}
|
||||
className={"min-h-0 flex-1 overflow-hidden"}
|
||||
onDragLeave={(e: DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
||||
setDropTargetBoth(null);
|
||||
}
|
||||
}}
|
||||
onDragOver={(e: DragEvent) => e.preventDefault()}
|
||||
onDrop={(e: DragEvent) => e.preventDefault()}
|
||||
>
|
||||
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
|
||||
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
|
||||
{scrollParent && (
|
||||
<PeersList
|
||||
data={filtered}
|
||||
scrollParent={scrollParent}
|
||||
dropTarget={dropTarget}
|
||||
onDropTarget={setDropTargetBoth}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
@@ -163,9 +215,11 @@ const ListTopSpacer = () => <div className={"h-2"} />;
|
||||
type PeersListProps = {
|
||||
data: PeerStatus[];
|
||||
scrollParent: HTMLElement;
|
||||
dropTarget: string | null;
|
||||
onDropTarget: (pubKey: string | null) => void;
|
||||
};
|
||||
|
||||
const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
const PeersList = ({ data, scrollParent, dropTarget, onDropTarget }: PeersListProps) => {
|
||||
const { setSelected } = usePeerDetail();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
@@ -221,9 +275,15 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
};
|
||||
|
||||
const ctx = useMemo<PeerRowContext>(
|
||||
() => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }),
|
||||
() => ({
|
||||
onKeyDown: handleRowKeyDown,
|
||||
onSelect: setSelected,
|
||||
setRowRef,
|
||||
dropTarget,
|
||||
onDropTarget,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[data, setSelected],
|
||||
[data, setSelected, dropTarget, onDropTarget],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -244,6 +304,8 @@ type PeerRowContext = {
|
||||
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
|
||||
onSelect: (peer: PeerStatus) => void;
|
||||
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
|
||||
dropTarget: string | null;
|
||||
onDropTarget: (pubKey: string | null) => void;
|
||||
};
|
||||
|
||||
const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): ReactNode => (
|
||||
@@ -253,6 +315,8 @@ const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): Re
|
||||
onKeyDown={ctx.onKeyDown}
|
||||
onSelect={ctx.onSelect}
|
||||
setRowRef={ctx.setRowRef}
|
||||
isDropTarget={ctx.dropTarget === peer.pubKey}
|
||||
onDropTarget={ctx.onDropTarget}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -262,9 +326,19 @@ type PeerRowProps = {
|
||||
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
|
||||
onSelect: (peer: PeerStatus) => void;
|
||||
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
|
||||
isDropTarget: boolean;
|
||||
onDropTarget: (pubKey: string | null) => void;
|
||||
};
|
||||
|
||||
const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) => {
|
||||
const PeerRow = ({
|
||||
peer,
|
||||
index,
|
||||
onKeyDown,
|
||||
onSelect,
|
||||
setRowRef,
|
||||
isDropTarget,
|
||||
onDropTarget,
|
||||
}: PeerRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = peer.connStatus === "Connected";
|
||||
const peerName = shortenDns(peer.fqdn) || peer.ip;
|
||||
@@ -272,12 +346,29 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
|
||||
const handleKey = (e: KeyboardEvent<Element>) => onKeyDown(e, index);
|
||||
return (
|
||||
<div
|
||||
onDragOver={(e: DragEvent) => {
|
||||
if (!isConnected) return;
|
||||
e.preventDefault();
|
||||
onDropTarget(peer.pubKey);
|
||||
}}
|
||||
className={cn(
|
||||
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-4",
|
||||
"transition-colors hover:bg-nb-gray-900/40",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
{isDropTarget && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-2 inset-y-0.5 z-10",
|
||||
"flex items-center justify-center gap-2 rounded-lg",
|
||||
"border border-netbird bg-nb-gray-940/90 text-netbird",
|
||||
)}
|
||||
>
|
||||
<SendIcon size={14} aria-hidden={"true"} />
|
||||
<span className={"text-sm"}>{t("peers.dropToSend")}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
|
||||
222
client/ui/frontend/src/modules/settings/SettingsFileSharing.tsx
Normal file
222
client/ui/frontend/src/modules/settings/SettingsFileSharing.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BanIcon, CheckIcon, XIcon } from "lucide-react";
|
||||
import { FileDrop } from "@bindings/services";
|
||||
import { FileDropSettings } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { PeerRule, ReceiveMode } from "@/lib/filedrop";
|
||||
import { shortenDns } from "@/lib/formatters";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
|
||||
const MODES: { value: ReceiveMode; labelKey: string; helpKey: string }[] = [
|
||||
{
|
||||
value: ReceiveMode.Off,
|
||||
labelKey: "settings.fileSharing.mode.off",
|
||||
helpKey: "settings.fileSharing.mode.off.help",
|
||||
},
|
||||
{
|
||||
value: ReceiveMode.Ask,
|
||||
labelKey: "settings.fileSharing.mode.ask",
|
||||
helpKey: "settings.fileSharing.mode.ask.help",
|
||||
},
|
||||
{
|
||||
value: ReceiveMode.Auto,
|
||||
labelKey: "settings.fileSharing.mode.auto",
|
||||
helpKey: "settings.fileSharing.mode.auto.help",
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsFileSharing() {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const [settings, setSettings] = useState<FileDropSettings | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
FileDrop.GetSettings()
|
||||
.then(setSettings)
|
||||
.catch((e: unknown) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
const apply = useCallback(
|
||||
async (next: FileDropSettings) => {
|
||||
const prev = settings;
|
||||
setSettings(next);
|
||||
try {
|
||||
await FileDrop.SetSettings(next);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setSettings(prev);
|
||||
setError(String(e));
|
||||
}
|
||||
},
|
||||
[settings],
|
||||
);
|
||||
|
||||
const changeDirectory = async () => {
|
||||
if (!settings) return;
|
||||
const dir = await FileDrop.PickDirectory();
|
||||
if (!dir) return;
|
||||
await apply(new FileDropSettings({ ...settings, destinationDir: dir }));
|
||||
};
|
||||
|
||||
const removeRule = async (peerKey: string) => {
|
||||
if (!settings) return;
|
||||
try {
|
||||
await FileDrop.SetPeerRule(peerKey, PeerRule.Default);
|
||||
setSettings(await FileDrop.GetSettings());
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const peerName = (key: string): string => {
|
||||
const peer = (status?.peers ?? []).find((p) => p.pubKey === key);
|
||||
return peer ? shortenDns(peer.fqdn) || peer.ip : key.slice(0, 12) + "…";
|
||||
};
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<SectionGroup title={t("settings.fileSharing.section")}>
|
||||
<HelpText>{error ?? t("settings.fileSharing.loading")}</HelpText>
|
||||
</SectionGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const rules = Object.entries(settings.peerRules ?? {});
|
||||
|
||||
return (
|
||||
<SectionGroup title={t("settings.fileSharing.section")}>
|
||||
{error && <HelpText className={"text-red-400"}>{error}</HelpText>}
|
||||
<div>
|
||||
<Label>{t("settings.fileSharing.mode.label")}</Label>
|
||||
<HelpText>{t("settings.fileSharing.mode.help")}</HelpText>
|
||||
<div
|
||||
role={"radiogroup"}
|
||||
aria-label={t("settings.fileSharing.mode.label")}
|
||||
className={"mt-2 flex flex-col gap-1.5"}
|
||||
>
|
||||
{MODES.map((mode) => {
|
||||
const active = settings.mode === mode.value;
|
||||
return (
|
||||
<button
|
||||
key={mode.value}
|
||||
type={"button"}
|
||||
role={"radio"}
|
||||
aria-checked={active}
|
||||
onClick={() =>
|
||||
void apply(
|
||||
new FileDropSettings({ ...settings, mode: mode.value }),
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border px-4 py-3 text-left",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
active
|
||||
? "border-netbird/60 bg-netbird/5"
|
||||
: "border-nb-gray-900 hover:border-nb-gray-800",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full border",
|
||||
active ? "border-netbird bg-netbird" : "border-nb-gray-600",
|
||||
)}
|
||||
>
|
||||
{active && <CheckIcon size={11} className={"text-white"} />}
|
||||
</span>
|
||||
<span className={"flex flex-col leading-tight"}>
|
||||
<span className={"text-sm text-nb-gray-100"}>
|
||||
{t(mode.labelKey)}
|
||||
</span>
|
||||
<span className={"text-xs text-nb-gray-400"}>
|
||||
{t(mode.helpKey)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("settings.fileSharing.destination.label")}</Label>
|
||||
<HelpText>{t("settings.fileSharing.destination.help")}</HelpText>
|
||||
<div className={"mt-2 flex items-center gap-3"}>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate rounded-md border border-nb-gray-900",
|
||||
"px-3 py-2 font-mono text-xs text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
{settings.destinationDir || t("settings.fileSharing.destination.unset")}
|
||||
</span>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
onClick={() => void changeDirectory()}
|
||||
>
|
||||
{t("settings.fileSharing.destination.change")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("settings.fileSharing.exceptions.label")}</Label>
|
||||
<HelpText>{t("settings.fileSharing.exceptions.help")}</HelpText>
|
||||
{rules.length === 0 ? (
|
||||
<HelpText className={"mt-2"}>
|
||||
{t("settings.fileSharing.exceptions.empty")}
|
||||
</HelpText>
|
||||
) : (
|
||||
<ul className={"mt-2 flex flex-col divide-y divide-nb-gray-920"}>
|
||||
{rules.map(([key, rule]) => (
|
||||
<li key={key} className={"flex items-center gap-3 py-2"}>
|
||||
{rule === PeerRule.Block ? (
|
||||
<BanIcon
|
||||
size={14}
|
||||
className={"shrink-0 text-red-400"}
|
||||
aria-hidden={"true"}
|
||||
/>
|
||||
) : (
|
||||
<CheckIcon
|
||||
size={14}
|
||||
className={"shrink-0 text-green-400"}
|
||||
aria-hidden={"true"}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={"min-w-0 flex-1 truncate text-sm text-nb-gray-100"}
|
||||
>
|
||||
{peerName(key)}
|
||||
</span>
|
||||
<span className={"shrink-0 text-xs text-nb-gray-400"}>
|
||||
{rule === PeerRule.Block
|
||||
? t("settings.fileSharing.exceptions.blocked")
|
||||
: t("settings.fileSharing.exceptions.always")}
|
||||
</span>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={t("settings.fileSharing.exceptions.remove")}
|
||||
onClick={() => void removeRule(key)}
|
||||
className={cn(
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
|
||||
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
)}
|
||||
>
|
||||
<XIcon size={13} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SectionGroup>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useClientVersion } from "@/contexts/ClientVersionContext.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
import {
|
||||
BoltIcon,
|
||||
FolderDownIcon,
|
||||
InfoIcon,
|
||||
LifeBuoyIcon,
|
||||
NetworkIcon,
|
||||
@@ -49,6 +50,11 @@ export const SettingsNavigation = () => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<VerticalTabs.Trigger
|
||||
value={"fileSharing"}
|
||||
icon={FolderDownIcon}
|
||||
title={t("settings.tabs.fileSharing")}
|
||||
/>
|
||||
{!features.disableProfiles && (
|
||||
<VerticalTabs.Trigger
|
||||
value={"profiles"}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
|
||||
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
|
||||
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
|
||||
import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx";
|
||||
import { SettingsFileSharing } from "@/modules/settings/SettingsFileSharing.tsx";
|
||||
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
|
||||
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
|
||||
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
||||
@@ -24,6 +25,7 @@ const enum Tab {
|
||||
General = "general",
|
||||
Network = "network",
|
||||
Security = "security",
|
||||
FileSharing = "fileSharing",
|
||||
Profiles = "profiles",
|
||||
SSH = "ssh",
|
||||
Advanced = "advanced",
|
||||
@@ -35,6 +37,7 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
|
||||
[Tab.General]: <SettingsGeneral />,
|
||||
[Tab.Network]: <SettingsNetwork />,
|
||||
[Tab.Security]: <SettingsSecurity />,
|
||||
[Tab.FileSharing]: <SettingsFileSharing />,
|
||||
[Tab.Profiles]: <ProfilesTab />,
|
||||
[Tab.SSH]: <SettingsSSH />,
|
||||
[Tab.Advanced]: <SettingsAdvanced />,
|
||||
@@ -53,6 +56,7 @@ export const SettingsPage = () => {
|
||||
[Tab.General]: true,
|
||||
[Tab.Network]: editable,
|
||||
[Tab.Security]: editable,
|
||||
[Tab.FileSharing]: true,
|
||||
[Tab.Profiles]: !features.disableProfiles,
|
||||
[Tab.SSH]: mdm.allowServerSSH ?? editable,
|
||||
[Tab.Advanced]: editable,
|
||||
|
||||
@@ -1338,5 +1338,174 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Vorgang fehlgeschlagen."
|
||||
},
|
||||
"nav.files.title": {
|
||||
"message": "Dateien"
|
||||
},
|
||||
"files.search.placeholder": {
|
||||
"message": "Übertragungen nach Datei oder Peer suchen"
|
||||
},
|
||||
"files.empty.title": {
|
||||
"message": "Noch keine Übertragungen"
|
||||
},
|
||||
"files.empty.description": {
|
||||
"message": "Gesendete und empfangene Dateien erscheinen hier."
|
||||
},
|
||||
"files.group.today": {
|
||||
"message": "Heute"
|
||||
},
|
||||
"files.group.yesterday": {
|
||||
"message": "Gestern"
|
||||
},
|
||||
"files.offer.subtitle": {
|
||||
"message": "{peer} möchte etwas senden"
|
||||
},
|
||||
"files.offer.accept": {
|
||||
"message": "Annehmen"
|
||||
},
|
||||
"files.offer.decline": {
|
||||
"message": "Ablehnen"
|
||||
},
|
||||
"files.row.to": {
|
||||
"message": "an {peer}"
|
||||
},
|
||||
"files.row.from": {
|
||||
"message": "von {peer}"
|
||||
},
|
||||
"files.state.pending": {
|
||||
"message": "Wartet"
|
||||
},
|
||||
"files.state.transferring": {
|
||||
"message": "Überträgt"
|
||||
},
|
||||
"files.state.progress": {
|
||||
"message": "{percent}%"
|
||||
},
|
||||
"files.state.received": {
|
||||
"message": "Empfangen"
|
||||
},
|
||||
"files.state.sent": {
|
||||
"message": "Gesendet"
|
||||
},
|
||||
"files.state.declined": {
|
||||
"message": "Abgelehnt"
|
||||
},
|
||||
"files.state.expired": {
|
||||
"message": "Keine Antwort"
|
||||
},
|
||||
"files.state.cancelled": {
|
||||
"message": "Abgebrochen"
|
||||
},
|
||||
"files.state.failed": {
|
||||
"message": "Fehlgeschlagen"
|
||||
},
|
||||
"files.action.copy": {
|
||||
"message": "Kopieren"
|
||||
},
|
||||
"files.action.reveal": {
|
||||
"message": "Im Ordner anzeigen"
|
||||
},
|
||||
"files.action.cancel": {
|
||||
"message": "Abbrechen"
|
||||
},
|
||||
"files.action.more": {
|
||||
"message": "Mehr"
|
||||
},
|
||||
"files.action.open": {
|
||||
"message": "Öffnen"
|
||||
},
|
||||
"files.action.alwaysAccept": {
|
||||
"message": "Von diesem Peer immer annehmen"
|
||||
},
|
||||
"files.action.block": {
|
||||
"message": "Diesen Peer blockieren"
|
||||
},
|
||||
"files.action.delete": {
|
||||
"message": "Löschen"
|
||||
},
|
||||
"peers.dropToSend": {
|
||||
"message": "Zum Senden ablegen"
|
||||
},
|
||||
"peers.details.sendFile": {
|
||||
"message": "Datei senden…"
|
||||
},
|
||||
"peers.details.sendClipboard": {
|
||||
"message": "Zwischenablage senden"
|
||||
},
|
||||
"peers.details.sendClipboard.empty": {
|
||||
"message": "Zwischenablage ist leer"
|
||||
},
|
||||
"settings.tabs.fileSharing": {
|
||||
"message": "Dateifreigabe"
|
||||
},
|
||||
"settings.fileSharing.section": {
|
||||
"message": "Dateiempfang"
|
||||
},
|
||||
"settings.fileSharing.loading": {
|
||||
"message": "Lädt…"
|
||||
},
|
||||
"settings.fileSharing.mode.label": {
|
||||
"message": "Dateien von Peers empfangen"
|
||||
},
|
||||
"settings.fileSharing.mode.help": {
|
||||
"message": "Wie dieses Gerät eingehende Dateiangebote behandelt."
|
||||
},
|
||||
"settings.fileSharing.mode.off": {
|
||||
"message": "Aus"
|
||||
},
|
||||
"settings.fileSharing.mode.off.help": {
|
||||
"message": "Dieses Gerät nimmt keine Dateien an."
|
||||
},
|
||||
"settings.fileSharing.mode.ask": {
|
||||
"message": "Jedes Mal fragen"
|
||||
},
|
||||
"settings.fileSharing.mode.ask.help": {
|
||||
"message": "Jedes Angebot fragt zuerst nach Ihrer Zustimmung."
|
||||
},
|
||||
"settings.fileSharing.mode.auto": {
|
||||
"message": "Automatisch annehmen"
|
||||
},
|
||||
"settings.fileSharing.mode.auto.help": {
|
||||
"message": "Dateien kommen ohne Interaktion an."
|
||||
},
|
||||
"settings.fileSharing.destination.label": {
|
||||
"message": "Empfangene Dateien speichern unter"
|
||||
},
|
||||
"settings.fileSharing.destination.help": {
|
||||
"message": "Empfangene Dateien werden in diesen Ordner zugestellt."
|
||||
},
|
||||
"settings.fileSharing.destination.unset": {
|
||||
"message": "Nicht festgelegt"
|
||||
},
|
||||
"settings.fileSharing.destination.change": {
|
||||
"message": "Ändern…"
|
||||
},
|
||||
"settings.fileSharing.exceptions.label": {
|
||||
"message": "Ausnahmen pro Peer"
|
||||
},
|
||||
"settings.fileSharing.exceptions.help": {
|
||||
"message": "Überschreibungen zusätzlich zum Empfangsmodus."
|
||||
},
|
||||
"settings.fileSharing.exceptions.empty": {
|
||||
"message": "Keine Ausnahmen."
|
||||
},
|
||||
"settings.fileSharing.exceptions.blocked": {
|
||||
"message": "Blockiert"
|
||||
},
|
||||
"settings.fileSharing.exceptions.always": {
|
||||
"message": "Immer angenommen"
|
||||
},
|
||||
"settings.fileSharing.exceptions.remove": {
|
||||
"message": "Ausnahme entfernen"
|
||||
},
|
||||
"files.state.unreachable": {
|
||||
"message": "Abgelehnt",
|
||||
"description": "Failed-transfer reason: the peer's file drop port refused the offer."
|
||||
},
|
||||
"notify.filedrop.offer.title": {
|
||||
"message": "Eingehende Datei"
|
||||
},
|
||||
"notify.filedrop.always": {
|
||||
"message": "Immer annehmen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1810,5 +1810,229 @@
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "You can switch this on, but switching it back off needs {actor}:",
|
||||
"description": "Warning under the SSH authentication setting, which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
},
|
||||
"nav.files.title": {
|
||||
"message": "Files",
|
||||
"description": "Navigation label for the Files section (file transfers)."
|
||||
},
|
||||
"files.search.placeholder": {
|
||||
"message": "Search transfers by file or peer",
|
||||
"description": "Placeholder of the transfer search field."
|
||||
},
|
||||
"files.empty.title": {
|
||||
"message": "No transfers yet",
|
||||
"description": "Empty state title of the Files section."
|
||||
},
|
||||
"files.empty.description": {
|
||||
"message": "Files you send or receive will show up here.",
|
||||
"description": "Empty state description of the Files section."
|
||||
},
|
||||
"files.group.today": {
|
||||
"message": "Today",
|
||||
"description": "Day group header for today's transfers."
|
||||
},
|
||||
"files.group.yesterday": {
|
||||
"message": "Yesterday",
|
||||
"description": "Day group header for yesterday's transfers."
|
||||
},
|
||||
"files.offer.subtitle": {
|
||||
"message": "{peer} wants to send",
|
||||
"description": "Subtitle of a pending incoming offer. {peer} is the sender's name."
|
||||
},
|
||||
"files.offer.accept": {
|
||||
"message": "Accept",
|
||||
"description": "Accept button of a pending offer."
|
||||
},
|
||||
"files.offer.decline": {
|
||||
"message": "Decline",
|
||||
"description": "Decline button of a pending offer."
|
||||
},
|
||||
"files.row.to": {
|
||||
"message": "to {peer}",
|
||||
"description": "Direction label of a sent transfer. {peer} is the receiver's name."
|
||||
},
|
||||
"files.row.from": {
|
||||
"message": "from {peer}",
|
||||
"description": "Direction label of a received transfer. {peer} is the sender's name."
|
||||
},
|
||||
"files.state.pending": {
|
||||
"message": "Waiting",
|
||||
"description": "Transfer state: waiting for the receiver's decision."
|
||||
},
|
||||
"files.state.transferring": {
|
||||
"message": "Transferring",
|
||||
"description": "Transfer state: payload is streaming."
|
||||
},
|
||||
"files.state.progress": {
|
||||
"message": "{percent}%",
|
||||
"description": "Transfer progress. {percent} is a number."
|
||||
},
|
||||
"files.state.received": {
|
||||
"message": "Received",
|
||||
"description": "Transfer state: completed incoming transfer."
|
||||
},
|
||||
"files.state.sent": {
|
||||
"message": "Sent",
|
||||
"description": "Transfer state: completed outgoing transfer."
|
||||
},
|
||||
"files.state.declined": {
|
||||
"message": "Declined",
|
||||
"description": "Transfer state: the receiver declined."
|
||||
},
|
||||
"files.state.expired": {
|
||||
"message": "No response",
|
||||
"description": "Transfer state: the offer expired unanswered."
|
||||
},
|
||||
"files.state.cancelled": {
|
||||
"message": "Cancelled",
|
||||
"description": "Transfer state: cancelled by a user."
|
||||
},
|
||||
"files.state.failed": {
|
||||
"message": "Failed",
|
||||
"description": "Transfer state: the transfer failed."
|
||||
},
|
||||
"files.action.copy": {
|
||||
"message": "Copy",
|
||||
"description": "Copy a received text snippet."
|
||||
},
|
||||
"files.action.reveal": {
|
||||
"message": "Show in folder",
|
||||
"description": "Open the file manager at the delivered file."
|
||||
},
|
||||
"files.action.cancel": {
|
||||
"message": "Cancel",
|
||||
"description": "Cancel a running transfer."
|
||||
},
|
||||
"files.action.more": {
|
||||
"message": "More",
|
||||
"description": "Open the row's overflow menu."
|
||||
},
|
||||
"files.action.open": {
|
||||
"message": "Open",
|
||||
"description": "Open the delivered file."
|
||||
},
|
||||
"files.action.alwaysAccept": {
|
||||
"message": "Always accept from this peer",
|
||||
"description": "Per-sender exception shortcut."
|
||||
},
|
||||
"files.action.block": {
|
||||
"message": "Block this peer",
|
||||
"description": "Per-sender block shortcut."
|
||||
},
|
||||
"files.action.delete": {
|
||||
"message": "Delete",
|
||||
"description": "Remove the entry from the history."
|
||||
},
|
||||
"peers.dropToSend": {
|
||||
"message": "Drop to send",
|
||||
"description": "Overlay shown while dragging files over a peer row."
|
||||
},
|
||||
"peers.details.sendFile": {
|
||||
"message": "Send file…",
|
||||
"description": "Peer action opening the file picker."
|
||||
},
|
||||
"peers.details.sendClipboard": {
|
||||
"message": "Send clipboard",
|
||||
"description": "Peer action sending the clipboard text."
|
||||
},
|
||||
"peers.details.sendClipboard.empty": {
|
||||
"message": "Clipboard is empty",
|
||||
"description": "Error when the clipboard has no text."
|
||||
},
|
||||
"settings.tabs.fileSharing": {
|
||||
"message": "File sharing",
|
||||
"description": "Settings tab for file transfer policy."
|
||||
},
|
||||
"settings.fileSharing.section": {
|
||||
"message": "File receiving",
|
||||
"description": "Section title of the file receiving policy."
|
||||
},
|
||||
"settings.fileSharing.loading": {
|
||||
"message": "Loading…",
|
||||
"description": "Shown while the policy is being fetched."
|
||||
},
|
||||
"settings.fileSharing.mode.label": {
|
||||
"message": "Receive files from peers",
|
||||
"description": "Label of the receiving mode selector."
|
||||
},
|
||||
"settings.fileSharing.mode.help": {
|
||||
"message": "How this device handles incoming file offers.",
|
||||
"description": "Help text of the receiving mode selector."
|
||||
},
|
||||
"settings.fileSharing.mode.off": {
|
||||
"message": "Off",
|
||||
"description": "Receiving mode: no files are accepted."
|
||||
},
|
||||
"settings.fileSharing.mode.off.help": {
|
||||
"message": "This device does not accept files.",
|
||||
"description": "Help text of the Off mode."
|
||||
},
|
||||
"settings.fileSharing.mode.ask": {
|
||||
"message": "Ask every time",
|
||||
"description": "Receiving mode: each offer asks for consent."
|
||||
},
|
||||
"settings.fileSharing.mode.ask.help": {
|
||||
"message": "Every offer asks for your consent first.",
|
||||
"description": "Help text of the Ask mode."
|
||||
},
|
||||
"settings.fileSharing.mode.auto": {
|
||||
"message": "Auto-accept",
|
||||
"description": "Receiving mode: files arrive without interaction."
|
||||
},
|
||||
"settings.fileSharing.mode.auto.help": {
|
||||
"message": "Files arrive without interaction.",
|
||||
"description": "Help text of the Auto-accept mode."
|
||||
},
|
||||
"settings.fileSharing.destination.label": {
|
||||
"message": "Save received files to",
|
||||
"description": "Label of the delivery directory row."
|
||||
},
|
||||
"settings.fileSharing.destination.help": {
|
||||
"message": "Received files are delivered into this folder.",
|
||||
"description": "Help text of the delivery directory row."
|
||||
},
|
||||
"settings.fileSharing.destination.unset": {
|
||||
"message": "Not set",
|
||||
"description": "Placeholder when no delivery directory is configured."
|
||||
},
|
||||
"settings.fileSharing.destination.change": {
|
||||
"message": "Change…",
|
||||
"description": "Button opening the directory picker."
|
||||
},
|
||||
"settings.fileSharing.exceptions.label": {
|
||||
"message": "Per-peer exceptions",
|
||||
"description": "Label of the exception list."
|
||||
},
|
||||
"settings.fileSharing.exceptions.help": {
|
||||
"message": "Overrides on top of the receiving mode.",
|
||||
"description": "Help text of the exception list."
|
||||
},
|
||||
"settings.fileSharing.exceptions.empty": {
|
||||
"message": "No exceptions.",
|
||||
"description": "Shown when the exception list is empty."
|
||||
},
|
||||
"settings.fileSharing.exceptions.blocked": {
|
||||
"message": "Blocked",
|
||||
"description": "Exception kind: sender is blocked."
|
||||
},
|
||||
"settings.fileSharing.exceptions.always": {
|
||||
"message": "Always accepted",
|
||||
"description": "Exception kind: sender is always accepted."
|
||||
},
|
||||
"settings.fileSharing.exceptions.remove": {
|
||||
"message": "Remove exception",
|
||||
"description": "Button removing one exception."
|
||||
},
|
||||
"files.state.unreachable": {
|
||||
"message": "Declined",
|
||||
"description": "Failed-transfer reason: the peer's file drop port refused the offer."
|
||||
},
|
||||
"notify.filedrop.offer.title": {
|
||||
"message": "Incoming file",
|
||||
"description": "Title of the consent notification for an incoming file offer."
|
||||
},
|
||||
"notify.filedrop.always": {
|
||||
"message": "Always accept",
|
||||
"description": "Consent-notification button: accept this offer and auto-accept this sender from now on."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1338,5 +1338,174 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "La operación falló."
|
||||
},
|
||||
"nav.files.title": {
|
||||
"message": "Archivos"
|
||||
},
|
||||
"files.search.placeholder": {
|
||||
"message": "Buscar transferencias por archivo o peer"
|
||||
},
|
||||
"files.empty.title": {
|
||||
"message": "Aún no hay transferencias"
|
||||
},
|
||||
"files.empty.description": {
|
||||
"message": "Los archivos que envíes o recibas aparecerán aquí."
|
||||
},
|
||||
"files.group.today": {
|
||||
"message": "Hoy"
|
||||
},
|
||||
"files.group.yesterday": {
|
||||
"message": "Ayer"
|
||||
},
|
||||
"files.offer.subtitle": {
|
||||
"message": "{peer} quiere enviar"
|
||||
},
|
||||
"files.offer.accept": {
|
||||
"message": "Aceptar"
|
||||
},
|
||||
"files.offer.decline": {
|
||||
"message": "Rechazar"
|
||||
},
|
||||
"files.row.to": {
|
||||
"message": "a {peer}"
|
||||
},
|
||||
"files.row.from": {
|
||||
"message": "de {peer}"
|
||||
},
|
||||
"files.state.pending": {
|
||||
"message": "Esperando"
|
||||
},
|
||||
"files.state.transferring": {
|
||||
"message": "Transfiriendo"
|
||||
},
|
||||
"files.state.progress": {
|
||||
"message": "{percent}%"
|
||||
},
|
||||
"files.state.received": {
|
||||
"message": "Recibido"
|
||||
},
|
||||
"files.state.sent": {
|
||||
"message": "Enviado"
|
||||
},
|
||||
"files.state.declined": {
|
||||
"message": "Rechazado"
|
||||
},
|
||||
"files.state.expired": {
|
||||
"message": "Sin respuesta"
|
||||
},
|
||||
"files.state.cancelled": {
|
||||
"message": "Cancelado"
|
||||
},
|
||||
"files.state.failed": {
|
||||
"message": "Fallido"
|
||||
},
|
||||
"files.action.copy": {
|
||||
"message": "Copiar"
|
||||
},
|
||||
"files.action.reveal": {
|
||||
"message": "Mostrar en la carpeta"
|
||||
},
|
||||
"files.action.cancel": {
|
||||
"message": "Cancelar"
|
||||
},
|
||||
"files.action.more": {
|
||||
"message": "Más"
|
||||
},
|
||||
"files.action.open": {
|
||||
"message": "Abrir"
|
||||
},
|
||||
"files.action.alwaysAccept": {
|
||||
"message": "Aceptar siempre de este peer"
|
||||
},
|
||||
"files.action.block": {
|
||||
"message": "Bloquear este peer"
|
||||
},
|
||||
"files.action.delete": {
|
||||
"message": "Eliminar"
|
||||
},
|
||||
"peers.dropToSend": {
|
||||
"message": "Suelta para enviar"
|
||||
},
|
||||
"peers.details.sendFile": {
|
||||
"message": "Enviar archivo…"
|
||||
},
|
||||
"peers.details.sendClipboard": {
|
||||
"message": "Enviar portapapeles"
|
||||
},
|
||||
"peers.details.sendClipboard.empty": {
|
||||
"message": "El portapapeles está vacío"
|
||||
},
|
||||
"settings.tabs.fileSharing": {
|
||||
"message": "Compartir archivos"
|
||||
},
|
||||
"settings.fileSharing.section": {
|
||||
"message": "Recepción de archivos"
|
||||
},
|
||||
"settings.fileSharing.loading": {
|
||||
"message": "Cargando…"
|
||||
},
|
||||
"settings.fileSharing.mode.label": {
|
||||
"message": "Recibir archivos de peers"
|
||||
},
|
||||
"settings.fileSharing.mode.help": {
|
||||
"message": "Cómo maneja este dispositivo las ofertas de archivos entrantes."
|
||||
},
|
||||
"settings.fileSharing.mode.off": {
|
||||
"message": "Desactivado"
|
||||
},
|
||||
"settings.fileSharing.mode.off.help": {
|
||||
"message": "Este dispositivo no acepta archivos."
|
||||
},
|
||||
"settings.fileSharing.mode.ask": {
|
||||
"message": "Preguntar cada vez"
|
||||
},
|
||||
"settings.fileSharing.mode.ask.help": {
|
||||
"message": "Cada oferta pide primero tu consentimiento."
|
||||
},
|
||||
"settings.fileSharing.mode.auto": {
|
||||
"message": "Aceptar automáticamente"
|
||||
},
|
||||
"settings.fileSharing.mode.auto.help": {
|
||||
"message": "Los archivos llegan sin interacción."
|
||||
},
|
||||
"settings.fileSharing.destination.label": {
|
||||
"message": "Guardar archivos recibidos en"
|
||||
},
|
||||
"settings.fileSharing.destination.help": {
|
||||
"message": "Los archivos recibidos se entregan en esta carpeta."
|
||||
},
|
||||
"settings.fileSharing.destination.unset": {
|
||||
"message": "Sin configurar"
|
||||
},
|
||||
"settings.fileSharing.destination.change": {
|
||||
"message": "Cambiar…"
|
||||
},
|
||||
"settings.fileSharing.exceptions.label": {
|
||||
"message": "Excepciones por peer"
|
||||
},
|
||||
"settings.fileSharing.exceptions.help": {
|
||||
"message": "Anulaciones sobre el modo de recepción."
|
||||
},
|
||||
"settings.fileSharing.exceptions.empty": {
|
||||
"message": "Sin excepciones."
|
||||
},
|
||||
"settings.fileSharing.exceptions.blocked": {
|
||||
"message": "Bloqueado"
|
||||
},
|
||||
"settings.fileSharing.exceptions.always": {
|
||||
"message": "Siempre aceptado"
|
||||
},
|
||||
"settings.fileSharing.exceptions.remove": {
|
||||
"message": "Eliminar excepción"
|
||||
},
|
||||
"files.state.unreachable": {
|
||||
"message": "Rechazado",
|
||||
"description": "Failed-transfer reason: the peer's file drop port refused the offer."
|
||||
},
|
||||
"notify.filedrop.offer.title": {
|
||||
"message": "Archivo entrante"
|
||||
},
|
||||
"notify.filedrop.always": {
|
||||
"message": "Aceptar siempre"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1338,5 +1338,174 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "L’opération a échoué."
|
||||
},
|
||||
"nav.files.title": {
|
||||
"message": "Fichiers"
|
||||
},
|
||||
"files.search.placeholder": {
|
||||
"message": "Rechercher des transferts par fichier ou pair"
|
||||
},
|
||||
"files.empty.title": {
|
||||
"message": "Aucun transfert pour l'instant"
|
||||
},
|
||||
"files.empty.description": {
|
||||
"message": "Les fichiers envoyés ou reçus apparaîtront ici."
|
||||
},
|
||||
"files.group.today": {
|
||||
"message": "Aujourd'hui"
|
||||
},
|
||||
"files.group.yesterday": {
|
||||
"message": "Hier"
|
||||
},
|
||||
"files.offer.subtitle": {
|
||||
"message": "{peer} veut envoyer"
|
||||
},
|
||||
"files.offer.accept": {
|
||||
"message": "Accepter"
|
||||
},
|
||||
"files.offer.decline": {
|
||||
"message": "Refuser"
|
||||
},
|
||||
"files.row.to": {
|
||||
"message": "vers {peer}"
|
||||
},
|
||||
"files.row.from": {
|
||||
"message": "de {peer}"
|
||||
},
|
||||
"files.state.pending": {
|
||||
"message": "En attente"
|
||||
},
|
||||
"files.state.transferring": {
|
||||
"message": "Transfert"
|
||||
},
|
||||
"files.state.progress": {
|
||||
"message": "{percent}%"
|
||||
},
|
||||
"files.state.received": {
|
||||
"message": "Reçu"
|
||||
},
|
||||
"files.state.sent": {
|
||||
"message": "Envoyé"
|
||||
},
|
||||
"files.state.declined": {
|
||||
"message": "Refusé"
|
||||
},
|
||||
"files.state.expired": {
|
||||
"message": "Sans réponse"
|
||||
},
|
||||
"files.state.cancelled": {
|
||||
"message": "Annulé"
|
||||
},
|
||||
"files.state.failed": {
|
||||
"message": "Échoué"
|
||||
},
|
||||
"files.action.copy": {
|
||||
"message": "Copier"
|
||||
},
|
||||
"files.action.reveal": {
|
||||
"message": "Afficher dans le dossier"
|
||||
},
|
||||
"files.action.cancel": {
|
||||
"message": "Annuler"
|
||||
},
|
||||
"files.action.more": {
|
||||
"message": "Plus"
|
||||
},
|
||||
"files.action.open": {
|
||||
"message": "Ouvrir"
|
||||
},
|
||||
"files.action.alwaysAccept": {
|
||||
"message": "Toujours accepter de ce pair"
|
||||
},
|
||||
"files.action.block": {
|
||||
"message": "Bloquer ce pair"
|
||||
},
|
||||
"files.action.delete": {
|
||||
"message": "Supprimer"
|
||||
},
|
||||
"peers.dropToSend": {
|
||||
"message": "Déposer pour envoyer"
|
||||
},
|
||||
"peers.details.sendFile": {
|
||||
"message": "Envoyer un fichier…"
|
||||
},
|
||||
"peers.details.sendClipboard": {
|
||||
"message": "Envoyer le presse-papiers"
|
||||
},
|
||||
"peers.details.sendClipboard.empty": {
|
||||
"message": "Le presse-papiers est vide"
|
||||
},
|
||||
"settings.tabs.fileSharing": {
|
||||
"message": "Partage de fichiers"
|
||||
},
|
||||
"settings.fileSharing.section": {
|
||||
"message": "Réception de fichiers"
|
||||
},
|
||||
"settings.fileSharing.loading": {
|
||||
"message": "Chargement…"
|
||||
},
|
||||
"settings.fileSharing.mode.label": {
|
||||
"message": "Recevoir des fichiers des pairs"
|
||||
},
|
||||
"settings.fileSharing.mode.help": {
|
||||
"message": "Comment cet appareil traite les offres de fichiers entrantes."
|
||||
},
|
||||
"settings.fileSharing.mode.off": {
|
||||
"message": "Désactivé"
|
||||
},
|
||||
"settings.fileSharing.mode.off.help": {
|
||||
"message": "Cet appareil n'accepte pas de fichiers."
|
||||
},
|
||||
"settings.fileSharing.mode.ask": {
|
||||
"message": "Demander à chaque fois"
|
||||
},
|
||||
"settings.fileSharing.mode.ask.help": {
|
||||
"message": "Chaque offre demande d'abord votre accord."
|
||||
},
|
||||
"settings.fileSharing.mode.auto": {
|
||||
"message": "Acceptation automatique"
|
||||
},
|
||||
"settings.fileSharing.mode.auto.help": {
|
||||
"message": "Les fichiers arrivent sans interaction."
|
||||
},
|
||||
"settings.fileSharing.destination.label": {
|
||||
"message": "Enregistrer les fichiers reçus dans"
|
||||
},
|
||||
"settings.fileSharing.destination.help": {
|
||||
"message": "Les fichiers reçus sont déposés dans ce dossier."
|
||||
},
|
||||
"settings.fileSharing.destination.unset": {
|
||||
"message": "Non défini"
|
||||
},
|
||||
"settings.fileSharing.destination.change": {
|
||||
"message": "Modifier…"
|
||||
},
|
||||
"settings.fileSharing.exceptions.label": {
|
||||
"message": "Exceptions par pair"
|
||||
},
|
||||
"settings.fileSharing.exceptions.help": {
|
||||
"message": "Dérogations au mode de réception."
|
||||
},
|
||||
"settings.fileSharing.exceptions.empty": {
|
||||
"message": "Aucune exception."
|
||||
},
|
||||
"settings.fileSharing.exceptions.blocked": {
|
||||
"message": "Bloqué"
|
||||
},
|
||||
"settings.fileSharing.exceptions.always": {
|
||||
"message": "Toujours accepté"
|
||||
},
|
||||
"settings.fileSharing.exceptions.remove": {
|
||||
"message": "Supprimer l'exception"
|
||||
},
|
||||
"files.state.unreachable": {
|
||||
"message": "Refusé",
|
||||
"description": "Failed-transfer reason: the peer's file drop port refused the offer."
|
||||
},
|
||||
"notify.filedrop.offer.title": {
|
||||
"message": "Fichier entrant"
|
||||
},
|
||||
"notify.filedrop.always": {
|
||||
"message": "Toujours accepter"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1338,5 +1338,174 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "A művelet meghiúsult."
|
||||
},
|
||||
"nav.files.title": {
|
||||
"message": "Fájlok"
|
||||
},
|
||||
"files.search.placeholder": {
|
||||
"message": "Keresés fájl vagy peer szerint"
|
||||
},
|
||||
"files.empty.title": {
|
||||
"message": "Még nincs átvitel"
|
||||
},
|
||||
"files.empty.description": {
|
||||
"message": "Az elküldött és fogadott fájlok itt jelennek meg."
|
||||
},
|
||||
"files.group.today": {
|
||||
"message": "Ma"
|
||||
},
|
||||
"files.group.yesterday": {
|
||||
"message": "Tegnap"
|
||||
},
|
||||
"files.offer.subtitle": {
|
||||
"message": "{peer} küldeni szeretne"
|
||||
},
|
||||
"files.offer.accept": {
|
||||
"message": "Elfogadás"
|
||||
},
|
||||
"files.offer.decline": {
|
||||
"message": "Elutasítás"
|
||||
},
|
||||
"files.row.to": {
|
||||
"message": "ide: {peer}"
|
||||
},
|
||||
"files.row.from": {
|
||||
"message": "tőle: {peer}"
|
||||
},
|
||||
"files.state.pending": {
|
||||
"message": "Várakozás"
|
||||
},
|
||||
"files.state.transferring": {
|
||||
"message": "Átvitel folyamatban"
|
||||
},
|
||||
"files.state.progress": {
|
||||
"message": "{percent}%"
|
||||
},
|
||||
"files.state.received": {
|
||||
"message": "Fogadva"
|
||||
},
|
||||
"files.state.sent": {
|
||||
"message": "Elküldve"
|
||||
},
|
||||
"files.state.declined": {
|
||||
"message": "Elutasítva"
|
||||
},
|
||||
"files.state.expired": {
|
||||
"message": "Nincs válasz"
|
||||
},
|
||||
"files.state.cancelled": {
|
||||
"message": "Megszakítva"
|
||||
},
|
||||
"files.state.failed": {
|
||||
"message": "Sikertelen"
|
||||
},
|
||||
"files.action.copy": {
|
||||
"message": "Másolás"
|
||||
},
|
||||
"files.action.reveal": {
|
||||
"message": "Megjelenítés a mappában"
|
||||
},
|
||||
"files.action.cancel": {
|
||||
"message": "Megszakítás"
|
||||
},
|
||||
"files.action.more": {
|
||||
"message": "Továbbiak"
|
||||
},
|
||||
"files.action.open": {
|
||||
"message": "Megnyitás"
|
||||
},
|
||||
"files.action.alwaysAccept": {
|
||||
"message": "Mindig elfogadás ettől a peertől"
|
||||
},
|
||||
"files.action.block": {
|
||||
"message": "Peer tiltása"
|
||||
},
|
||||
"files.action.delete": {
|
||||
"message": "Törlés"
|
||||
},
|
||||
"peers.dropToSend": {
|
||||
"message": "Engedd el a küldéshez"
|
||||
},
|
||||
"peers.details.sendFile": {
|
||||
"message": "Fájl küldése…"
|
||||
},
|
||||
"peers.details.sendClipboard": {
|
||||
"message": "Vágólap küldése"
|
||||
},
|
||||
"peers.details.sendClipboard.empty": {
|
||||
"message": "A vágólap üres"
|
||||
},
|
||||
"settings.tabs.fileSharing": {
|
||||
"message": "Fájlmegosztás"
|
||||
},
|
||||
"settings.fileSharing.section": {
|
||||
"message": "Fájlfogadás"
|
||||
},
|
||||
"settings.fileSharing.loading": {
|
||||
"message": "Betöltés…"
|
||||
},
|
||||
"settings.fileSharing.mode.label": {
|
||||
"message": "Fájlok fogadása peerektől"
|
||||
},
|
||||
"settings.fileSharing.mode.help": {
|
||||
"message": "Így kezeli az eszköz a bejövő fájlfelajánlásokat."
|
||||
},
|
||||
"settings.fileSharing.mode.off": {
|
||||
"message": "Kikapcsolva"
|
||||
},
|
||||
"settings.fileSharing.mode.off.help": {
|
||||
"message": "Az eszköz nem fogad fájlokat."
|
||||
},
|
||||
"settings.fileSharing.mode.ask": {
|
||||
"message": "Mindig kérdezzen"
|
||||
},
|
||||
"settings.fileSharing.mode.ask.help": {
|
||||
"message": "Minden felajánlás először engedélyt kér."
|
||||
},
|
||||
"settings.fileSharing.mode.auto": {
|
||||
"message": "Automatikus elfogadás"
|
||||
},
|
||||
"settings.fileSharing.mode.auto.help": {
|
||||
"message": "A fájlok beavatkozás nélkül érkeznek."
|
||||
},
|
||||
"settings.fileSharing.destination.label": {
|
||||
"message": "Fogadott fájlok mentése ide"
|
||||
},
|
||||
"settings.fileSharing.destination.help": {
|
||||
"message": "A fogadott fájlok ebbe a mappába kerülnek."
|
||||
},
|
||||
"settings.fileSharing.destination.unset": {
|
||||
"message": "Nincs beállítva"
|
||||
},
|
||||
"settings.fileSharing.destination.change": {
|
||||
"message": "Módosítás…"
|
||||
},
|
||||
"settings.fileSharing.exceptions.label": {
|
||||
"message": "Peerenkénti kivételek"
|
||||
},
|
||||
"settings.fileSharing.exceptions.help": {
|
||||
"message": "A fogadási módot felülíró szabályok."
|
||||
},
|
||||
"settings.fileSharing.exceptions.empty": {
|
||||
"message": "Nincsenek kivételek."
|
||||
},
|
||||
"settings.fileSharing.exceptions.blocked": {
|
||||
"message": "Tiltva"
|
||||
},
|
||||
"settings.fileSharing.exceptions.always": {
|
||||
"message": "Mindig elfogadva"
|
||||
},
|
||||
"settings.fileSharing.exceptions.remove": {
|
||||
"message": "Kivétel eltávolítása"
|
||||
},
|
||||
"files.state.unreachable": {
|
||||
"message": "Elutasítva",
|
||||
"description": "Failed-transfer reason: the peer's file drop port refused the offer."
|
||||
},
|
||||
"notify.filedrop.offer.title": {
|
||||
"message": "Bejövő fájl"
|
||||
},
|
||||
"notify.filedrop.always": {
|
||||
"message": "Mindig elfogadás"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1338,5 +1338,174 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Operazione non riuscita."
|
||||
},
|
||||
"nav.files.title": {
|
||||
"message": "File"
|
||||
},
|
||||
"files.search.placeholder": {
|
||||
"message": "Cerca trasferimenti per file o peer"
|
||||
},
|
||||
"files.empty.title": {
|
||||
"message": "Nessun trasferimento"
|
||||
},
|
||||
"files.empty.description": {
|
||||
"message": "I file inviati o ricevuti appariranno qui."
|
||||
},
|
||||
"files.group.today": {
|
||||
"message": "Oggi"
|
||||
},
|
||||
"files.group.yesterday": {
|
||||
"message": "Ieri"
|
||||
},
|
||||
"files.offer.subtitle": {
|
||||
"message": "{peer} vuole inviare"
|
||||
},
|
||||
"files.offer.accept": {
|
||||
"message": "Accetta"
|
||||
},
|
||||
"files.offer.decline": {
|
||||
"message": "Rifiuta"
|
||||
},
|
||||
"files.row.to": {
|
||||
"message": "a {peer}"
|
||||
},
|
||||
"files.row.from": {
|
||||
"message": "da {peer}"
|
||||
},
|
||||
"files.state.pending": {
|
||||
"message": "In attesa"
|
||||
},
|
||||
"files.state.transferring": {
|
||||
"message": "Trasferimento"
|
||||
},
|
||||
"files.state.progress": {
|
||||
"message": "{percent}%"
|
||||
},
|
||||
"files.state.received": {
|
||||
"message": "Ricevuto"
|
||||
},
|
||||
"files.state.sent": {
|
||||
"message": "Inviato"
|
||||
},
|
||||
"files.state.declined": {
|
||||
"message": "Rifiutato"
|
||||
},
|
||||
"files.state.expired": {
|
||||
"message": "Nessuna risposta"
|
||||
},
|
||||
"files.state.cancelled": {
|
||||
"message": "Annullato"
|
||||
},
|
||||
"files.state.failed": {
|
||||
"message": "Non riuscito"
|
||||
},
|
||||
"files.action.copy": {
|
||||
"message": "Copia"
|
||||
},
|
||||
"files.action.reveal": {
|
||||
"message": "Mostra nella cartella"
|
||||
},
|
||||
"files.action.cancel": {
|
||||
"message": "Annulla"
|
||||
},
|
||||
"files.action.more": {
|
||||
"message": "Altro"
|
||||
},
|
||||
"files.action.open": {
|
||||
"message": "Apri"
|
||||
},
|
||||
"files.action.alwaysAccept": {
|
||||
"message": "Accetta sempre da questo peer"
|
||||
},
|
||||
"files.action.block": {
|
||||
"message": "Blocca questo peer"
|
||||
},
|
||||
"files.action.delete": {
|
||||
"message": "Elimina"
|
||||
},
|
||||
"peers.dropToSend": {
|
||||
"message": "Rilascia per inviare"
|
||||
},
|
||||
"peers.details.sendFile": {
|
||||
"message": "Invia file…"
|
||||
},
|
||||
"peers.details.sendClipboard": {
|
||||
"message": "Invia appunti"
|
||||
},
|
||||
"peers.details.sendClipboard.empty": {
|
||||
"message": "Gli appunti sono vuoti"
|
||||
},
|
||||
"settings.tabs.fileSharing": {
|
||||
"message": "Condivisione file"
|
||||
},
|
||||
"settings.fileSharing.section": {
|
||||
"message": "Ricezione file"
|
||||
},
|
||||
"settings.fileSharing.loading": {
|
||||
"message": "Caricamento…"
|
||||
},
|
||||
"settings.fileSharing.mode.label": {
|
||||
"message": "Ricevi file dai peer"
|
||||
},
|
||||
"settings.fileSharing.mode.help": {
|
||||
"message": "Come questo dispositivo gestisce le offerte di file in arrivo."
|
||||
},
|
||||
"settings.fileSharing.mode.off": {
|
||||
"message": "Disattivato"
|
||||
},
|
||||
"settings.fileSharing.mode.off.help": {
|
||||
"message": "Questo dispositivo non accetta file."
|
||||
},
|
||||
"settings.fileSharing.mode.ask": {
|
||||
"message": "Chiedi ogni volta"
|
||||
},
|
||||
"settings.fileSharing.mode.ask.help": {
|
||||
"message": "Ogni offerta chiede prima il tuo consenso."
|
||||
},
|
||||
"settings.fileSharing.mode.auto": {
|
||||
"message": "Accettazione automatica"
|
||||
},
|
||||
"settings.fileSharing.mode.auto.help": {
|
||||
"message": "I file arrivano senza interazione."
|
||||
},
|
||||
"settings.fileSharing.destination.label": {
|
||||
"message": "Salva i file ricevuti in"
|
||||
},
|
||||
"settings.fileSharing.destination.help": {
|
||||
"message": "I file ricevuti vengono consegnati in questa cartella."
|
||||
},
|
||||
"settings.fileSharing.destination.unset": {
|
||||
"message": "Non impostato"
|
||||
},
|
||||
"settings.fileSharing.destination.change": {
|
||||
"message": "Cambia…"
|
||||
},
|
||||
"settings.fileSharing.exceptions.label": {
|
||||
"message": "Eccezioni per peer"
|
||||
},
|
||||
"settings.fileSharing.exceptions.help": {
|
||||
"message": "Sostituzioni rispetto alla modalità di ricezione."
|
||||
},
|
||||
"settings.fileSharing.exceptions.empty": {
|
||||
"message": "Nessuna eccezione."
|
||||
},
|
||||
"settings.fileSharing.exceptions.blocked": {
|
||||
"message": "Bloccato"
|
||||
},
|
||||
"settings.fileSharing.exceptions.always": {
|
||||
"message": "Sempre accettato"
|
||||
},
|
||||
"settings.fileSharing.exceptions.remove": {
|
||||
"message": "Rimuovi eccezione"
|
||||
},
|
||||
"files.state.unreachable": {
|
||||
"message": "Rifiutato",
|
||||
"description": "Failed-transfer reason: the peer's file drop port refused the offer."
|
||||
},
|
||||
"notify.filedrop.offer.title": {
|
||||
"message": "File in arrivo"
|
||||
},
|
||||
"notify.filedrop.always": {
|
||||
"message": "Accetta sempre"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1338,5 +1338,174 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "操作に失敗しました。"
|
||||
},
|
||||
"nav.files.title": {
|
||||
"message": "ファイル"
|
||||
},
|
||||
"files.search.placeholder": {
|
||||
"message": "ファイルまたはピアで転送を検索"
|
||||
},
|
||||
"files.empty.title": {
|
||||
"message": "転送はまだありません"
|
||||
},
|
||||
"files.empty.description": {
|
||||
"message": "送受信したファイルがここに表示されます。"
|
||||
},
|
||||
"files.group.today": {
|
||||
"message": "今日"
|
||||
},
|
||||
"files.group.yesterday": {
|
||||
"message": "昨日"
|
||||
},
|
||||
"files.offer.subtitle": {
|
||||
"message": "{peer} が送信を希望しています"
|
||||
},
|
||||
"files.offer.accept": {
|
||||
"message": "承認"
|
||||
},
|
||||
"files.offer.decline": {
|
||||
"message": "拒否"
|
||||
},
|
||||
"files.row.to": {
|
||||
"message": "{peer} へ"
|
||||
},
|
||||
"files.row.from": {
|
||||
"message": "{peer} から"
|
||||
},
|
||||
"files.state.pending": {
|
||||
"message": "待機中"
|
||||
},
|
||||
"files.state.transferring": {
|
||||
"message": "転送中"
|
||||
},
|
||||
"files.state.progress": {
|
||||
"message": "{percent}%"
|
||||
},
|
||||
"files.state.received": {
|
||||
"message": "受信済み"
|
||||
},
|
||||
"files.state.sent": {
|
||||
"message": "送信済み"
|
||||
},
|
||||
"files.state.declined": {
|
||||
"message": "拒否されました"
|
||||
},
|
||||
"files.state.expired": {
|
||||
"message": "応答なし"
|
||||
},
|
||||
"files.state.cancelled": {
|
||||
"message": "キャンセル済み"
|
||||
},
|
||||
"files.state.failed": {
|
||||
"message": "失敗"
|
||||
},
|
||||
"files.action.copy": {
|
||||
"message": "コピー"
|
||||
},
|
||||
"files.action.reveal": {
|
||||
"message": "フォルダーで表示"
|
||||
},
|
||||
"files.action.cancel": {
|
||||
"message": "キャンセル"
|
||||
},
|
||||
"files.action.more": {
|
||||
"message": "その他"
|
||||
},
|
||||
"files.action.open": {
|
||||
"message": "開く"
|
||||
},
|
||||
"files.action.alwaysAccept": {
|
||||
"message": "このピアからは常に承認"
|
||||
},
|
||||
"files.action.block": {
|
||||
"message": "このピアをブロック"
|
||||
},
|
||||
"files.action.delete": {
|
||||
"message": "削除"
|
||||
},
|
||||
"peers.dropToSend": {
|
||||
"message": "ドロップして送信"
|
||||
},
|
||||
"peers.details.sendFile": {
|
||||
"message": "ファイルを送信…"
|
||||
},
|
||||
"peers.details.sendClipboard": {
|
||||
"message": "クリップボードを送信"
|
||||
},
|
||||
"peers.details.sendClipboard.empty": {
|
||||
"message": "クリップボードは空です"
|
||||
},
|
||||
"settings.tabs.fileSharing": {
|
||||
"message": "ファイル共有"
|
||||
},
|
||||
"settings.fileSharing.section": {
|
||||
"message": "ファイル受信"
|
||||
},
|
||||
"settings.fileSharing.loading": {
|
||||
"message": "読み込み中…"
|
||||
},
|
||||
"settings.fileSharing.mode.label": {
|
||||
"message": "ピアからファイルを受信"
|
||||
},
|
||||
"settings.fileSharing.mode.help": {
|
||||
"message": "このデバイスが受信ファイルの提案を処理する方法。"
|
||||
},
|
||||
"settings.fileSharing.mode.off": {
|
||||
"message": "オフ"
|
||||
},
|
||||
"settings.fileSharing.mode.off.help": {
|
||||
"message": "このデバイスはファイルを受け付けません。"
|
||||
},
|
||||
"settings.fileSharing.mode.ask": {
|
||||
"message": "毎回確認"
|
||||
},
|
||||
"settings.fileSharing.mode.ask.help": {
|
||||
"message": "各提案はまず同意を求めます。"
|
||||
},
|
||||
"settings.fileSharing.mode.auto": {
|
||||
"message": "自動承認"
|
||||
},
|
||||
"settings.fileSharing.mode.auto.help": {
|
||||
"message": "ファイルは操作なしで届きます。"
|
||||
},
|
||||
"settings.fileSharing.destination.label": {
|
||||
"message": "受信ファイルの保存先"
|
||||
},
|
||||
"settings.fileSharing.destination.help": {
|
||||
"message": "受信したファイルはこのフォルダーに保存されます。"
|
||||
},
|
||||
"settings.fileSharing.destination.unset": {
|
||||
"message": "未設定"
|
||||
},
|
||||
"settings.fileSharing.destination.change": {
|
||||
"message": "変更…"
|
||||
},
|
||||
"settings.fileSharing.exceptions.label": {
|
||||
"message": "ピアごとの例外"
|
||||
},
|
||||
"settings.fileSharing.exceptions.help": {
|
||||
"message": "受信モードを上書きする設定。"
|
||||
},
|
||||
"settings.fileSharing.exceptions.empty": {
|
||||
"message": "例外はありません。"
|
||||
},
|
||||
"settings.fileSharing.exceptions.blocked": {
|
||||
"message": "ブロック済み"
|
||||
},
|
||||
"settings.fileSharing.exceptions.always": {
|
||||
"message": "常に承認"
|
||||
},
|
||||
"settings.fileSharing.exceptions.remove": {
|
||||
"message": "例外を削除"
|
||||
},
|
||||
"files.state.unreachable": {
|
||||
"message": "拒否されました",
|
||||
"description": "Failed-transfer reason: the peer's file drop port refused the offer."
|
||||
},
|
||||
"notify.filedrop.offer.title": {
|
||||
"message": "受信ファイル"
|
||||
},
|
||||
"notify.filedrop.always": {
|
||||
"message": "常に承認"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1338,5 +1338,174 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "A operação falhou."
|
||||
},
|
||||
"nav.files.title": {
|
||||
"message": "Arquivos"
|
||||
},
|
||||
"files.search.placeholder": {
|
||||
"message": "Buscar transferências por arquivo ou peer"
|
||||
},
|
||||
"files.empty.title": {
|
||||
"message": "Nenhuma transferência ainda"
|
||||
},
|
||||
"files.empty.description": {
|
||||
"message": "Os arquivos enviados ou recebidos aparecerão aqui."
|
||||
},
|
||||
"files.group.today": {
|
||||
"message": "Hoje"
|
||||
},
|
||||
"files.group.yesterday": {
|
||||
"message": "Ontem"
|
||||
},
|
||||
"files.offer.subtitle": {
|
||||
"message": "{peer} quer enviar"
|
||||
},
|
||||
"files.offer.accept": {
|
||||
"message": "Aceitar"
|
||||
},
|
||||
"files.offer.decline": {
|
||||
"message": "Recusar"
|
||||
},
|
||||
"files.row.to": {
|
||||
"message": "para {peer}"
|
||||
},
|
||||
"files.row.from": {
|
||||
"message": "de {peer}"
|
||||
},
|
||||
"files.state.pending": {
|
||||
"message": "Aguardando"
|
||||
},
|
||||
"files.state.transferring": {
|
||||
"message": "Transferindo"
|
||||
},
|
||||
"files.state.progress": {
|
||||
"message": "{percent}%"
|
||||
},
|
||||
"files.state.received": {
|
||||
"message": "Recebido"
|
||||
},
|
||||
"files.state.sent": {
|
||||
"message": "Enviado"
|
||||
},
|
||||
"files.state.declined": {
|
||||
"message": "Recusado"
|
||||
},
|
||||
"files.state.expired": {
|
||||
"message": "Sem resposta"
|
||||
},
|
||||
"files.state.cancelled": {
|
||||
"message": "Cancelado"
|
||||
},
|
||||
"files.state.failed": {
|
||||
"message": "Falhou"
|
||||
},
|
||||
"files.action.copy": {
|
||||
"message": "Copiar"
|
||||
},
|
||||
"files.action.reveal": {
|
||||
"message": "Mostrar na pasta"
|
||||
},
|
||||
"files.action.cancel": {
|
||||
"message": "Cancelar"
|
||||
},
|
||||
"files.action.more": {
|
||||
"message": "Mais"
|
||||
},
|
||||
"files.action.open": {
|
||||
"message": "Abrir"
|
||||
},
|
||||
"files.action.alwaysAccept": {
|
||||
"message": "Sempre aceitar deste peer"
|
||||
},
|
||||
"files.action.block": {
|
||||
"message": "Bloquear este peer"
|
||||
},
|
||||
"files.action.delete": {
|
||||
"message": "Excluir"
|
||||
},
|
||||
"peers.dropToSend": {
|
||||
"message": "Solte para enviar"
|
||||
},
|
||||
"peers.details.sendFile": {
|
||||
"message": "Enviar arquivo…"
|
||||
},
|
||||
"peers.details.sendClipboard": {
|
||||
"message": "Enviar área de transferência"
|
||||
},
|
||||
"peers.details.sendClipboard.empty": {
|
||||
"message": "A área de transferência está vazia"
|
||||
},
|
||||
"settings.tabs.fileSharing": {
|
||||
"message": "Compartilhamento de arquivos"
|
||||
},
|
||||
"settings.fileSharing.section": {
|
||||
"message": "Recebimento de arquivos"
|
||||
},
|
||||
"settings.fileSharing.loading": {
|
||||
"message": "Carregando…"
|
||||
},
|
||||
"settings.fileSharing.mode.label": {
|
||||
"message": "Receber arquivos de peers"
|
||||
},
|
||||
"settings.fileSharing.mode.help": {
|
||||
"message": "Como este dispositivo trata ofertas de arquivos recebidas."
|
||||
},
|
||||
"settings.fileSharing.mode.off": {
|
||||
"message": "Desativado"
|
||||
},
|
||||
"settings.fileSharing.mode.off.help": {
|
||||
"message": "Este dispositivo não aceita arquivos."
|
||||
},
|
||||
"settings.fileSharing.mode.ask": {
|
||||
"message": "Perguntar sempre"
|
||||
},
|
||||
"settings.fileSharing.mode.ask.help": {
|
||||
"message": "Cada oferta pede primeiro o seu consentimento."
|
||||
},
|
||||
"settings.fileSharing.mode.auto": {
|
||||
"message": "Aceitar automaticamente"
|
||||
},
|
||||
"settings.fileSharing.mode.auto.help": {
|
||||
"message": "Os arquivos chegam sem interação."
|
||||
},
|
||||
"settings.fileSharing.destination.label": {
|
||||
"message": "Salvar arquivos recebidos em"
|
||||
},
|
||||
"settings.fileSharing.destination.help": {
|
||||
"message": "Os arquivos recebidos são entregues nesta pasta."
|
||||
},
|
||||
"settings.fileSharing.destination.unset": {
|
||||
"message": "Não definido"
|
||||
},
|
||||
"settings.fileSharing.destination.change": {
|
||||
"message": "Alterar…"
|
||||
},
|
||||
"settings.fileSharing.exceptions.label": {
|
||||
"message": "Exceções por peer"
|
||||
},
|
||||
"settings.fileSharing.exceptions.help": {
|
||||
"message": "Substituições sobre o modo de recebimento."
|
||||
},
|
||||
"settings.fileSharing.exceptions.empty": {
|
||||
"message": "Sem exceções."
|
||||
},
|
||||
"settings.fileSharing.exceptions.blocked": {
|
||||
"message": "Bloqueado"
|
||||
},
|
||||
"settings.fileSharing.exceptions.always": {
|
||||
"message": "Sempre aceito"
|
||||
},
|
||||
"settings.fileSharing.exceptions.remove": {
|
||||
"message": "Remover exceção"
|
||||
},
|
||||
"files.state.unreachable": {
|
||||
"message": "Recusado",
|
||||
"description": "Failed-transfer reason: the peer's file drop port refused the offer."
|
||||
},
|
||||
"notify.filedrop.offer.title": {
|
||||
"message": "Arquivo recebido"
|
||||
},
|
||||
"notify.filedrop.always": {
|
||||
"message": "Sempre aceitar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1338,5 +1338,174 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Не удалось выполнить операцию."
|
||||
},
|
||||
"nav.files.title": {
|
||||
"message": "Файлы"
|
||||
},
|
||||
"files.search.placeholder": {
|
||||
"message": "Поиск передач по файлу или пиру"
|
||||
},
|
||||
"files.empty.title": {
|
||||
"message": "Передач пока нет"
|
||||
},
|
||||
"files.empty.description": {
|
||||
"message": "Отправленные и полученные файлы появятся здесь."
|
||||
},
|
||||
"files.group.today": {
|
||||
"message": "Сегодня"
|
||||
},
|
||||
"files.group.yesterday": {
|
||||
"message": "Вчера"
|
||||
},
|
||||
"files.offer.subtitle": {
|
||||
"message": "{peer} хочет отправить"
|
||||
},
|
||||
"files.offer.accept": {
|
||||
"message": "Принять"
|
||||
},
|
||||
"files.offer.decline": {
|
||||
"message": "Отклонить"
|
||||
},
|
||||
"files.row.to": {
|
||||
"message": "кому: {peer}"
|
||||
},
|
||||
"files.row.from": {
|
||||
"message": "от {peer}"
|
||||
},
|
||||
"files.state.pending": {
|
||||
"message": "Ожидание"
|
||||
},
|
||||
"files.state.transferring": {
|
||||
"message": "Передача"
|
||||
},
|
||||
"files.state.progress": {
|
||||
"message": "{percent}%"
|
||||
},
|
||||
"files.state.received": {
|
||||
"message": "Получено"
|
||||
},
|
||||
"files.state.sent": {
|
||||
"message": "Отправлено"
|
||||
},
|
||||
"files.state.declined": {
|
||||
"message": "Отклонено"
|
||||
},
|
||||
"files.state.expired": {
|
||||
"message": "Нет ответа"
|
||||
},
|
||||
"files.state.cancelled": {
|
||||
"message": "Отменено"
|
||||
},
|
||||
"files.state.failed": {
|
||||
"message": "Ошибка"
|
||||
},
|
||||
"files.action.copy": {
|
||||
"message": "Копировать"
|
||||
},
|
||||
"files.action.reveal": {
|
||||
"message": "Показать в папке"
|
||||
},
|
||||
"files.action.cancel": {
|
||||
"message": "Отменить"
|
||||
},
|
||||
"files.action.more": {
|
||||
"message": "Ещё"
|
||||
},
|
||||
"files.action.open": {
|
||||
"message": "Открыть"
|
||||
},
|
||||
"files.action.alwaysAccept": {
|
||||
"message": "Всегда принимать от этого пира"
|
||||
},
|
||||
"files.action.block": {
|
||||
"message": "Заблокировать этого пира"
|
||||
},
|
||||
"files.action.delete": {
|
||||
"message": "Удалить"
|
||||
},
|
||||
"peers.dropToSend": {
|
||||
"message": "Отпустите для отправки"
|
||||
},
|
||||
"peers.details.sendFile": {
|
||||
"message": "Отправить файл…"
|
||||
},
|
||||
"peers.details.sendClipboard": {
|
||||
"message": "Отправить буфер обмена"
|
||||
},
|
||||
"peers.details.sendClipboard.empty": {
|
||||
"message": "Буфер обмена пуст"
|
||||
},
|
||||
"settings.tabs.fileSharing": {
|
||||
"message": "Обмен файлами"
|
||||
},
|
||||
"settings.fileSharing.section": {
|
||||
"message": "Получение файлов"
|
||||
},
|
||||
"settings.fileSharing.loading": {
|
||||
"message": "Загрузка…"
|
||||
},
|
||||
"settings.fileSharing.mode.label": {
|
||||
"message": "Получать файлы от пиров"
|
||||
},
|
||||
"settings.fileSharing.mode.help": {
|
||||
"message": "Как это устройство обрабатывает входящие предложения файлов."
|
||||
},
|
||||
"settings.fileSharing.mode.off": {
|
||||
"message": "Выключено"
|
||||
},
|
||||
"settings.fileSharing.mode.off.help": {
|
||||
"message": "Это устройство не принимает файлы."
|
||||
},
|
||||
"settings.fileSharing.mode.ask": {
|
||||
"message": "Спрашивать каждый раз"
|
||||
},
|
||||
"settings.fileSharing.mode.ask.help": {
|
||||
"message": "Каждое предложение сначала запрашивает согласие."
|
||||
},
|
||||
"settings.fileSharing.mode.auto": {
|
||||
"message": "Автоприём"
|
||||
},
|
||||
"settings.fileSharing.mode.auto.help": {
|
||||
"message": "Файлы приходят без подтверждения."
|
||||
},
|
||||
"settings.fileSharing.destination.label": {
|
||||
"message": "Сохранять полученные файлы в"
|
||||
},
|
||||
"settings.fileSharing.destination.help": {
|
||||
"message": "Полученные файлы помещаются в эту папку."
|
||||
},
|
||||
"settings.fileSharing.destination.unset": {
|
||||
"message": "Не задано"
|
||||
},
|
||||
"settings.fileSharing.destination.change": {
|
||||
"message": "Изменить…"
|
||||
},
|
||||
"settings.fileSharing.exceptions.label": {
|
||||
"message": "Исключения для пиров"
|
||||
},
|
||||
"settings.fileSharing.exceptions.help": {
|
||||
"message": "Переопределения поверх режима приёма."
|
||||
},
|
||||
"settings.fileSharing.exceptions.empty": {
|
||||
"message": "Исключений нет."
|
||||
},
|
||||
"settings.fileSharing.exceptions.blocked": {
|
||||
"message": "Заблокирован"
|
||||
},
|
||||
"settings.fileSharing.exceptions.always": {
|
||||
"message": "Всегда принимается"
|
||||
},
|
||||
"settings.fileSharing.exceptions.remove": {
|
||||
"message": "Удалить исключение"
|
||||
},
|
||||
"files.state.unreachable": {
|
||||
"message": "Отклонено",
|
||||
"description": "Failed-transfer reason: the peer's file drop port refused the offer."
|
||||
},
|
||||
"notify.filedrop.offer.title": {
|
||||
"message": "Входящий файл"
|
||||
},
|
||||
"notify.filedrop.always": {
|
||||
"message": "Всегда принимать"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1338,5 +1338,174 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "操作失败。"
|
||||
},
|
||||
"nav.files.title": {
|
||||
"message": "文件"
|
||||
},
|
||||
"files.search.placeholder": {
|
||||
"message": "按文件或对等设备搜索传输"
|
||||
},
|
||||
"files.empty.title": {
|
||||
"message": "暂无传输"
|
||||
},
|
||||
"files.empty.description": {
|
||||
"message": "您发送或接收的文件将显示在这里。"
|
||||
},
|
||||
"files.group.today": {
|
||||
"message": "今天"
|
||||
},
|
||||
"files.group.yesterday": {
|
||||
"message": "昨天"
|
||||
},
|
||||
"files.offer.subtitle": {
|
||||
"message": "{peer} 想要发送"
|
||||
},
|
||||
"files.offer.accept": {
|
||||
"message": "接受"
|
||||
},
|
||||
"files.offer.decline": {
|
||||
"message": "拒绝"
|
||||
},
|
||||
"files.row.to": {
|
||||
"message": "发送至 {peer}"
|
||||
},
|
||||
"files.row.from": {
|
||||
"message": "来自 {peer}"
|
||||
},
|
||||
"files.state.pending": {
|
||||
"message": "等待中"
|
||||
},
|
||||
"files.state.transferring": {
|
||||
"message": "传输中"
|
||||
},
|
||||
"files.state.progress": {
|
||||
"message": "{percent}%"
|
||||
},
|
||||
"files.state.received": {
|
||||
"message": "已接收"
|
||||
},
|
||||
"files.state.sent": {
|
||||
"message": "已发送"
|
||||
},
|
||||
"files.state.declined": {
|
||||
"message": "已拒绝"
|
||||
},
|
||||
"files.state.expired": {
|
||||
"message": "无响应"
|
||||
},
|
||||
"files.state.cancelled": {
|
||||
"message": "已取消"
|
||||
},
|
||||
"files.state.failed": {
|
||||
"message": "失败"
|
||||
},
|
||||
"files.action.copy": {
|
||||
"message": "复制"
|
||||
},
|
||||
"files.action.reveal": {
|
||||
"message": "在文件夹中显示"
|
||||
},
|
||||
"files.action.cancel": {
|
||||
"message": "取消"
|
||||
},
|
||||
"files.action.more": {
|
||||
"message": "更多"
|
||||
},
|
||||
"files.action.open": {
|
||||
"message": "打开"
|
||||
},
|
||||
"files.action.alwaysAccept": {
|
||||
"message": "始终接受此对等设备"
|
||||
},
|
||||
"files.action.block": {
|
||||
"message": "屏蔽此对等设备"
|
||||
},
|
||||
"files.action.delete": {
|
||||
"message": "删除"
|
||||
},
|
||||
"peers.dropToSend": {
|
||||
"message": "放开即发送"
|
||||
},
|
||||
"peers.details.sendFile": {
|
||||
"message": "发送文件…"
|
||||
},
|
||||
"peers.details.sendClipboard": {
|
||||
"message": "发送剪贴板"
|
||||
},
|
||||
"peers.details.sendClipboard.empty": {
|
||||
"message": "剪贴板为空"
|
||||
},
|
||||
"settings.tabs.fileSharing": {
|
||||
"message": "文件共享"
|
||||
},
|
||||
"settings.fileSharing.section": {
|
||||
"message": "文件接收"
|
||||
},
|
||||
"settings.fileSharing.loading": {
|
||||
"message": "加载中…"
|
||||
},
|
||||
"settings.fileSharing.mode.label": {
|
||||
"message": "接收对等设备的文件"
|
||||
},
|
||||
"settings.fileSharing.mode.help": {
|
||||
"message": "此设备如何处理传入的文件提议。"
|
||||
},
|
||||
"settings.fileSharing.mode.off": {
|
||||
"message": "关闭"
|
||||
},
|
||||
"settings.fileSharing.mode.off.help": {
|
||||
"message": "此设备不接受文件。"
|
||||
},
|
||||
"settings.fileSharing.mode.ask": {
|
||||
"message": "每次询问"
|
||||
},
|
||||
"settings.fileSharing.mode.ask.help": {
|
||||
"message": "每个提议都会先征求您的同意。"
|
||||
},
|
||||
"settings.fileSharing.mode.auto": {
|
||||
"message": "自动接受"
|
||||
},
|
||||
"settings.fileSharing.mode.auto.help": {
|
||||
"message": "文件无需交互即可到达。"
|
||||
},
|
||||
"settings.fileSharing.destination.label": {
|
||||
"message": "接收文件保存至"
|
||||
},
|
||||
"settings.fileSharing.destination.help": {
|
||||
"message": "接收的文件将存放到此文件夹。"
|
||||
},
|
||||
"settings.fileSharing.destination.unset": {
|
||||
"message": "未设置"
|
||||
},
|
||||
"settings.fileSharing.destination.change": {
|
||||
"message": "更改…"
|
||||
},
|
||||
"settings.fileSharing.exceptions.label": {
|
||||
"message": "按对等设备的例外"
|
||||
},
|
||||
"settings.fileSharing.exceptions.help": {
|
||||
"message": "覆盖接收模式的规则。"
|
||||
},
|
||||
"settings.fileSharing.exceptions.empty": {
|
||||
"message": "无例外。"
|
||||
},
|
||||
"settings.fileSharing.exceptions.blocked": {
|
||||
"message": "已屏蔽"
|
||||
},
|
||||
"settings.fileSharing.exceptions.always": {
|
||||
"message": "始终接受"
|
||||
},
|
||||
"settings.fileSharing.exceptions.remove": {
|
||||
"message": "移除例外"
|
||||
},
|
||||
"files.state.unreachable": {
|
||||
"message": "已拒绝",
|
||||
"description": "Failed-transfer reason: the peer's file drop port refused the offer."
|
||||
},
|
||||
"notify.filedrop.offer.title": {
|
||||
"message": "传入文件"
|
||||
},
|
||||
"notify.filedrop.always": {
|
||||
"message": "始终接受"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ func (s *stringList) Set(v string) error {
|
||||
|
||||
type registeredServices struct {
|
||||
connection *services.Connection
|
||||
fileDrop *services.FileDrop
|
||||
authSession *authsession.Session
|
||||
settings *services.Settings
|
||||
networks *services.Networks
|
||||
@@ -123,9 +124,11 @@ func main() {
|
||||
// the React frontend calls, keeping the generated TS surface minimal.
|
||||
authSession := authsession.NewSession(conn)
|
||||
networks := services.NewNetworks(conn)
|
||||
fileDrop := services.NewFileDrop(conn)
|
||||
|
||||
registerServices(app, conn, registeredServices{
|
||||
connection: connection,
|
||||
fileDrop: fileDrop,
|
||||
authSession: authSession,
|
||||
settings: settings,
|
||||
networks: networks,
|
||||
@@ -170,6 +173,7 @@ func main() {
|
||||
|
||||
tray = NewTray(app, window, TrayServices{
|
||||
Connection: connection,
|
||||
FileDrop: fileDrop,
|
||||
Settings: settings,
|
||||
Profiles: profiles,
|
||||
Networks: networks,
|
||||
@@ -326,6 +330,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) {
|
||||
app.RegisterService(application.NewService(services.NewForwarding(conn)))
|
||||
app.RegisterService(application.NewService(s.profiles))
|
||||
app.RegisterService(application.NewService(services.NewDebug(conn)))
|
||||
app.RegisterService(application.NewService(s.fileDrop))
|
||||
app.RegisterService(application.NewService(s.update))
|
||||
app.RegisterService(application.NewService(s.daemonFeed))
|
||||
app.RegisterService(application.NewService(s.notifier))
|
||||
@@ -359,6 +364,7 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
|
||||
BackgroundColour: services.WindowBackgroundColour,
|
||||
URL: "/",
|
||||
DisableResize: true,
|
||||
EnableFileDrop: true,
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
Mac: services.AppleMacOSAppearanceOptions(),
|
||||
@@ -368,6 +374,10 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
|
||||
},
|
||||
})
|
||||
|
||||
window.RegisterHook(events.Common.WindowFilesDropped, func(e *application.WindowEvent) {
|
||||
app.Event.Emit(services.EventFilesDropped, e.Context().DroppedFiles())
|
||||
})
|
||||
|
||||
// Hide instead of quit on close; "really quit" is reached via tray -> Quit.
|
||||
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||
if services.ShuttingDown() {
|
||||
|
||||
@@ -85,6 +85,14 @@ func (n *Notifier) SendNotificationWithActions(options notifications.Notificatio
|
||||
return n.inner.SendNotificationWithActions(options)
|
||||
}
|
||||
|
||||
// RemoveNotification withdraws a delivered notification, a no-op without a backend.
|
||||
func (n *Notifier) RemoveNotification(identifier string) error {
|
||||
if !n.available.Load() {
|
||||
return nil
|
||||
}
|
||||
return n.inner.RemoveNotification(identifier)
|
||||
}
|
||||
|
||||
func (n *Notifier) RegisterNotificationCategory(category notifications.NotificationCategory) error {
|
||||
if !n.available.Load() {
|
||||
return nil
|
||||
|
||||
@@ -33,6 +33,9 @@ const (
|
||||
// subscribers needn't filter the notification firehose. Consumers branch on
|
||||
// SessionWarning.Final to tell the T-10 event from the T-2 fallback.
|
||||
EventSessionWarning = "netbird:session:warning"
|
||||
// EventFileDrop is a typed sibling of EventDaemonNotification for file
|
||||
// transfer milestones; the payload is a FileDropEvent.
|
||||
EventFileDrop = "netbird:filedrop"
|
||||
|
||||
// StatusDaemonUnavailable is the synthetic Status emitted when the daemon's
|
||||
// gRPC socket is unreachable. No internal.Status* collides with this label.
|
||||
@@ -57,6 +60,29 @@ type Emitter interface {
|
||||
Emit(name string, data ...any) bool
|
||||
}
|
||||
|
||||
// FileDropEvent is the payload of EventFileDrop. Kind is one of "offer",
|
||||
// "completed", "failed", "withdrawn".
|
||||
type FileDropEvent struct {
|
||||
Kind string `json:"kind"`
|
||||
TransferID string `json:"transferId"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func fileDropEventKind(metaKind string) (string, bool) {
|
||||
switch metaKind {
|
||||
case proto.MetadataKindFileDropOffer:
|
||||
return "offer", true
|
||||
case proto.MetadataKindFileDropCompleted:
|
||||
return "completed", true
|
||||
case proto.MetadataKindFileDropFailed:
|
||||
return "failed", true
|
||||
case proto.MetadataKindFileDropWithdrawn:
|
||||
return "withdrawn", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// SystemEvent is the frontend-facing shape of a daemon SystemEvent.
|
||||
type SystemEvent struct {
|
||||
ID string `json:"id"`
|
||||
@@ -486,6 +512,16 @@ func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if kind, ok := fileDropEventKind(se.Metadata[proto.MetadataKindKey]); ok {
|
||||
s.emitter.Emit(EventFileDrop, FileDropEvent{
|
||||
Kind: kind,
|
||||
TransferID: se.Metadata[proto.MetadataFileDropTransferKey],
|
||||
Message: se.UserMessage,
|
||||
})
|
||||
if se.UserMessage == "" {
|
||||
return
|
||||
}
|
||||
}
|
||||
s.emitter.Emit(EventDaemonNotification, se)
|
||||
if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok {
|
||||
s.emitter.Emit(EventSessionWarning, warn)
|
||||
|
||||
238
client/ui/services/filedrop.go
Normal file
238
client/ui/services/filedrop.go
Normal file
@@ -0,0 +1,238 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// EventFilesDropped carries the absolute paths of files natively dropped on the
|
||||
// main window.
|
||||
const EventFilesDropped = "netbird:files:dropped"
|
||||
|
||||
// FileDropFile mirrors one payload item of a transfer.
|
||||
type FileDropFile struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"contentType"`
|
||||
IsText bool `json:"isText"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// FileDropTransfer mirrors proto.FileDropTransfer for the frontend.
|
||||
type FileDropTransfer struct {
|
||||
ID string `json:"id"`
|
||||
Outgoing bool `json:"outgoing"`
|
||||
PeerKey string `json:"peerKey"`
|
||||
PeerName string `json:"peerName"`
|
||||
Files []FileDropFile `json:"files"`
|
||||
State int32 `json:"state"`
|
||||
Transferred int64 `json:"transferred"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeliveredPaths []string `json:"deliveredPaths"`
|
||||
Error string `json:"error"`
|
||||
Reason int32 `json:"reason"`
|
||||
}
|
||||
|
||||
// FileDropSettings mirrors the daemon's receiving policy with ordinal enums.
|
||||
type FileDropSettings struct {
|
||||
Mode int32 `json:"mode"`
|
||||
DestinationDir string `json:"destinationDir"`
|
||||
PeerRules map[string]int32 `json:"peerRules"`
|
||||
}
|
||||
|
||||
// FileDrop bridges the daemon's file transfer RPCs to the frontend.
|
||||
type FileDrop struct {
|
||||
conn DaemonConn
|
||||
}
|
||||
|
||||
func NewFileDrop(conn DaemonConn) *FileDrop {
|
||||
return &FileDrop{conn: conn}
|
||||
}
|
||||
|
||||
// List returns the transfer history, newest first.
|
||||
func (s *FileDrop) List(ctx context.Context) ([]FileDropTransfer, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := cli.FileDropListTransfers(ctx, &proto.FileDropListTransfersRequest{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]FileDropTransfer, 0, len(resp.GetTransfers()))
|
||||
for _, t := range resp.GetTransfers() {
|
||||
out = append(out, fileDropTransferFromProto(t))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Send starts a transfer of local files and/or an inline text to a peer.
|
||||
func (s *FileDrop) Send(ctx context.Context, peerKey string, paths []string, text string) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := cli.FileDropSend(ctx, &proto.FileDropSendRequest{
|
||||
PeerKey: peerKey,
|
||||
Paths: paths,
|
||||
Text: text,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.GetTransferId(), nil
|
||||
}
|
||||
|
||||
// PickFiles opens the native file picker; an empty result means cancelled.
|
||||
func (s *FileDrop) PickFiles(_ context.Context) ([]string, error) {
|
||||
return application.Get().Dialog.OpenFile().PromptForMultipleSelection()
|
||||
}
|
||||
|
||||
// PickDirectory opens the native directory picker; empty means cancelled.
|
||||
func (s *FileDrop) PickDirectory(_ context.Context) (string, error) {
|
||||
return application.Get().Dialog.OpenFile().
|
||||
CanChooseDirectories(true).
|
||||
CanChooseFiles(false).
|
||||
PromptForSingleSelection()
|
||||
}
|
||||
|
||||
// Decide accepts or declines a pending incoming offer.
|
||||
func (s *FileDrop) Decide(ctx context.Context, id string, accept bool) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.FileDropDecide(ctx, &proto.FileDropDecideRequest{TransferId: id, Accept: accept})
|
||||
return err
|
||||
}
|
||||
|
||||
// Cancel aborts a transfer.
|
||||
func (s *FileDrop) Cancel(ctx context.Context, id string) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.FileDropCancel(ctx, &proto.FileDropCancelRequest{TransferId: id})
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete removes a history entry.
|
||||
func (s *FileDrop) Delete(ctx context.Context, id string) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.FileDropDeleteTransfer(ctx, &proto.FileDropDeleteTransferRequest{TransferId: id})
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSettings returns the receiving policy of the active profile.
|
||||
func (s *FileDrop) GetSettings(ctx context.Context) (FileDropSettings, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return FileDropSettings{}, err
|
||||
}
|
||||
resp, err := cli.FileDropGetSettings(ctx, &proto.FileDropGetSettingsRequest{})
|
||||
if err != nil {
|
||||
return FileDropSettings{}, err
|
||||
}
|
||||
|
||||
rules := make(map[string]int32, len(resp.GetPeerRules()))
|
||||
for key, rule := range resp.GetPeerRules() {
|
||||
rules[key] = int32(rule)
|
||||
}
|
||||
return FileDropSettings{
|
||||
Mode: int32(resp.GetMode()),
|
||||
DestinationDir: resp.GetDestinationDir(),
|
||||
PeerRules: rules,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetSettings updates the receiving policy of the active profile.
|
||||
func (s *FileDrop) SetSettings(ctx context.Context, settings FileDropSettings) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.FileDropSetSettings(ctx, &proto.FileDropSetSettingsRequest{
|
||||
Mode: proto.FileDropMode(settings.Mode),
|
||||
DestinationDir: settings.DestinationDir,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPeerRule sets or clears a per-sender exception.
|
||||
func (s *FileDrop) SetPeerRule(ctx context.Context, peerKey string, rule int32) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.FileDropSetPeerRule(ctx, &proto.FileDropSetPeerRuleRequest{
|
||||
PeerKey: peerKey,
|
||||
Rule: proto.FileDropRule(rule),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ClipboardText returns the current clipboard text, empty when unavailable.
|
||||
func (s *FileDrop) ClipboardText(_ context.Context) string {
|
||||
text, ok := application.Get().Clipboard.Text()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// Reveal opens the OS file manager focused on a delivered file.
|
||||
func (s *FileDrop) Reveal(_ context.Context, path string) error {
|
||||
if path == "" {
|
||||
return errors.New("empty path")
|
||||
}
|
||||
return revealFile(path)
|
||||
}
|
||||
|
||||
// Open opens a delivered file with its default application.
|
||||
func (s *FileDrop) Open(_ context.Context, path string) error {
|
||||
if path == "" {
|
||||
return errors.New("empty path")
|
||||
}
|
||||
return openFile(path)
|
||||
}
|
||||
|
||||
func fileDropTransferFromProto(t *proto.FileDropTransfer) FileDropTransfer {
|
||||
files := make([]FileDropFile, 0, len(t.GetFiles()))
|
||||
for _, f := range t.GetFiles() {
|
||||
files = append(files, FileDropFile{
|
||||
Name: f.GetName(),
|
||||
Size: f.GetSize(),
|
||||
ContentType: f.GetContentType(),
|
||||
IsText: f.GetIsText(),
|
||||
Text: f.GetText(),
|
||||
})
|
||||
}
|
||||
return FileDropTransfer{
|
||||
ID: t.GetId(),
|
||||
Outgoing: t.GetOutgoing(),
|
||||
PeerKey: t.GetPeerKey(),
|
||||
PeerName: t.GetPeerName(),
|
||||
Files: files,
|
||||
State: int32(t.GetState()),
|
||||
Transferred: t.GetTransferred(),
|
||||
TotalSize: t.GetTotalSize(),
|
||||
CreatedAt: t.GetCreatedAt().AsTime(),
|
||||
UpdatedAt: t.GetUpdatedAt().AsTime(),
|
||||
DeliveredPaths: append([]string{}, t.GetDeliveredPaths()...),
|
||||
Error: t.GetError(),
|
||||
Reason: int32(t.GetReason()),
|
||||
}
|
||||
}
|
||||
16
client/ui/services/openfile_other.go
Normal file
16
client/ui/services/openfile_other.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build !android && !ios && !freebsd && !js && !windows
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func openFile(path string) error {
|
||||
opener := "xdg-open"
|
||||
if runtime.GOOS == "darwin" {
|
||||
opener = "open"
|
||||
}
|
||||
return exec.Command(opener, path).Start()
|
||||
}
|
||||
9
client/ui/services/openfile_windows.go
Normal file
9
client/ui/services/openfile_windows.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func openFile(path string) error {
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", path).Start() //nolint:gosec
|
||||
}
|
||||
@@ -41,6 +41,7 @@ const (
|
||||
// stays under the linter's parameter-count threshold.
|
||||
type TrayServices struct {
|
||||
Connection *services.Connection
|
||||
FileDrop *services.FileDrop
|
||||
Settings *services.Settings
|
||||
Profiles *services.Profiles
|
||||
Networks *services.Networks
|
||||
@@ -200,6 +201,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
|
||||
|
||||
app.Event.On(services.EventStatusSnapshot, t.onStatusEvent)
|
||||
app.Event.On(services.EventDaemonNotification, t.onSystemEvent)
|
||||
app.Event.On(services.EventFileDrop, t.onFileDropEvent)
|
||||
// Refresh the Profiles submenu on ProfileSwitcher's change event. A
|
||||
// switch on an idle daemon drives no status transition, so without this
|
||||
// hook a React-initiated switch leaves the tray's submenu stale.
|
||||
@@ -217,6 +219,8 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
|
||||
// Startup populates appName/registry path on Windows; before app.Run()
|
||||
// the category lookup silently falls back to a plain notification.
|
||||
t.registerSessionWarningCategory()
|
||||
t.registerFileDropCategory()
|
||||
t.registerNotificationResponses()
|
||||
})
|
||||
|
||||
t.loc.Watch(func(i18n.LanguageCode) { t.applyLanguage() })
|
||||
|
||||
@@ -65,6 +65,11 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
|
||||
// category/severity so a daemon-side reword still lands here. Final warning
|
||||
// auto-opens the SessionExpiration dialog with no notification (the dialog is
|
||||
// the last-chance reminder; doubling up would be noise).
|
||||
if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindFileDropOffer {
|
||||
t.notifyFileDropOffer(se)
|
||||
return
|
||||
}
|
||||
|
||||
if isDeadlineRejected {
|
||||
t.notify(
|
||||
t.loc.T("notify.sessionDeadlineRejected.title"),
|
||||
|
||||
146
client/ui/tray_filedrop.go
Normal file
146
client/ui/tray_filedrop.go
Normal file
@@ -0,0 +1,146 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
)
|
||||
|
||||
const (
|
||||
notifyCategoryFileDropOffer = "netbird-filedrop-offer"
|
||||
notifyActionFileDropAccept = "filedrop-accept"
|
||||
notifyActionFileDropDecline = "filedrop-decline"
|
||||
notifyActionFileDropAlways = "filedrop-always-accept"
|
||||
notifyIDFileDropPrefix = "netbird-filedrop-"
|
||||
|
||||
fileDropDecideTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// registerFileDropCategory wires the consent-notification category. Errors are
|
||||
// swallowed: the worst case is a plain notification without buttons.
|
||||
func (t *Tray) registerFileDropCategory() {
|
||||
if t.svc.Notifier == nil {
|
||||
return
|
||||
}
|
||||
if err := t.svc.Notifier.RegisterNotificationCategory(notifications.NotificationCategory{
|
||||
ID: notifyCategoryFileDropOffer,
|
||||
Actions: []notifications.NotificationAction{
|
||||
{ID: notifyActionFileDropAccept, Title: t.loc.T("files.offer.accept")},
|
||||
{ID: notifyActionFileDropDecline, Title: t.loc.T("files.offer.decline")},
|
||||
{ID: notifyActionFileDropAlways, Title: t.loc.T("notify.filedrop.always")},
|
||||
},
|
||||
}); err != nil {
|
||||
log.Debugf("register file drop notification category: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// notifyFileDropOffer raises the consent notification with Accept, Decline, and
|
||||
// Always accept actions, falling back to a plain notification.
|
||||
func (t *Tray) notifyFileDropOffer(se services.SystemEvent) {
|
||||
if t.svc.Notifier == nil {
|
||||
return
|
||||
}
|
||||
|
||||
transferID := se.Metadata[proto.MetadataFileDropTransferKey]
|
||||
title := t.loc.T("notify.filedrop.offer.title")
|
||||
if transferID == "" {
|
||||
t.notify(title, se.UserMessage, notifyIDEvent+se.ID)
|
||||
return
|
||||
}
|
||||
|
||||
err := safeSendNotification(t.svc.Notifier.SendNotificationWithActions, "filedrop offer with actions", notifications.NotificationOptions{
|
||||
ID: notifyIDFileDropPrefix + transferID,
|
||||
Title: title,
|
||||
Body: se.UserMessage,
|
||||
CategoryID: notifyCategoryFileDropOffer,
|
||||
Data: map[string]interface{}{
|
||||
proto.MetadataFileDropTransferKey: transferID,
|
||||
proto.MetadataFileDropPeerKey: se.Metadata[proto.MetadataFileDropPeerKey],
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.notify(title, se.UserMessage, notifyIDFileDropPrefix+transferID)
|
||||
}
|
||||
}
|
||||
|
||||
// handleFileDropResponse acts on the consent-notification buttons. The transfer ID
|
||||
// is recovered from the notification ID when the platform drops the user info; a
|
||||
// body click just brings the app forward, so a stray tap can never accept.
|
||||
func (t *Tray) handleFileDropResponse(resp notifications.NotificationResponse) {
|
||||
transferID, _ := resp.UserInfo[proto.MetadataFileDropTransferKey].(string)
|
||||
if transferID == "" && strings.HasPrefix(resp.ID, notifyIDFileDropPrefix) {
|
||||
transferID = strings.TrimPrefix(resp.ID, notifyIDFileDropPrefix)
|
||||
}
|
||||
peerKey, _ := resp.UserInfo[proto.MetadataFileDropPeerKey].(string)
|
||||
|
||||
switch resp.ActionIdentifier {
|
||||
case notifyActionFileDropAccept:
|
||||
go t.fileDropDecide(transferID, true)
|
||||
case notifyActionFileDropDecline:
|
||||
go t.fileDropDecide(transferID, false)
|
||||
case notifyActionFileDropAlways:
|
||||
go t.fileDropAlwaysAccept(transferID, peerKey)
|
||||
default:
|
||||
t.ShowWindow()
|
||||
}
|
||||
}
|
||||
|
||||
// onFileDropEvent withdraws the consent notification when the sender cancelled the
|
||||
// offer or it expired.
|
||||
func (t *Tray) onFileDropEvent(ev *application.CustomEvent) {
|
||||
fe, ok := ev.Data.(services.FileDropEvent)
|
||||
if !ok || fe.Kind != "withdrawn" || fe.TransferID == "" {
|
||||
return
|
||||
}
|
||||
t.removeFileDropNotification(fe.TransferID)
|
||||
}
|
||||
|
||||
func (t *Tray) fileDropDecide(transferID string, accept bool) {
|
||||
if t.svc.FileDrop == nil || transferID == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), fileDropDecideTimeout)
|
||||
defer cancel()
|
||||
if err := t.svc.FileDrop.Decide(ctx, transferID, accept); err != nil {
|
||||
log.Debugf("file drop decide from notification: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tray) fileDropAlwaysAccept(transferID, peerKey string) {
|
||||
if t.svc.FileDrop == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), fileDropDecideTimeout)
|
||||
defer cancel()
|
||||
if peerKey != "" {
|
||||
if err := t.svc.FileDrop.SetPeerRule(ctx, peerKey, int32(proto.FileDropRule_FILE_DROP_RULE_ALWAYS)); err != nil {
|
||||
log.Debugf("file drop always-accept rule from notification: %v", err)
|
||||
}
|
||||
}
|
||||
t.fileDropDecide(transferID, true)
|
||||
}
|
||||
|
||||
// removeFileDropNotification is panic-guarded like safeSendNotification: a dead
|
||||
// notification bus on Linux panics inside godbus on any call.
|
||||
func (t *Tray) removeFileDropNotification(transferID string) {
|
||||
if t.svc.Notifier == nil || services.ShuttingDown() {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Errorf("remove filedrop notification: recovered from panic (notification bus unavailable): %v", r)
|
||||
}
|
||||
}()
|
||||
if err := t.svc.Notifier.RemoveNotification(notifyIDFileDropPrefix + transferID); err != nil {
|
||||
log.Debugf("remove filedrop notification: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,27 @@ func safeSendNotification(send sendFn, what string, opts notifications.Notificat
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerNotificationResponses installs the single Wails notification-response
|
||||
// callback and fans it out per category; a second OnNotificationResponse call
|
||||
// would silently replace the first, so every category must branch here.
|
||||
func (t *Tray) registerNotificationResponses() {
|
||||
if t.svc.Notifier == nil {
|
||||
return
|
||||
}
|
||||
t.svc.Notifier.OnNotificationResponse(func(result notifications.NotificationResult) {
|
||||
if result.Error != nil {
|
||||
log.Debugf("notification response error: %v", result.Error)
|
||||
return
|
||||
}
|
||||
switch result.Response.CategoryID {
|
||||
case notifyCategorySessionWarning:
|
||||
t.handleSessionWarningResponse(result.Response)
|
||||
case notifyCategoryFileDropOffer:
|
||||
t.handleFileDropResponse(result.Response)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it
|
||||
// is reachable but too old for this UI. A probe error means the daemon isn't
|
||||
// reachable (not outdated), so it is left to the normal connection flow.
|
||||
|
||||
@@ -177,22 +177,16 @@ func (t *Tray) registerSessionWarningCategory() {
|
||||
}); err != nil {
|
||||
log.Debugf("register session-warning notification category: %v", err)
|
||||
}
|
||||
t.svc.Notifier.OnNotificationResponse(func(result notifications.NotificationResult) {
|
||||
if result.Error != nil {
|
||||
log.Debugf("notification response error: %v", result.Error)
|
||||
return
|
||||
}
|
||||
if result.Response.CategoryID != notifyCategorySessionWarning {
|
||||
return
|
||||
}
|
||||
switch result.Response.ActionIdentifier {
|
||||
case notifyActionExtendNow, notifications.DefaultActionIdentifier:
|
||||
// DefaultActionIdentifier is the body-click on platforms with no separate buttons; treat as Extend.
|
||||
go t.runExtendSession()
|
||||
case notifyActionDismiss:
|
||||
go t.dismissSessionWarning()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (t *Tray) handleSessionWarningResponse(resp notifications.NotificationResponse) {
|
||||
switch resp.ActionIdentifier {
|
||||
case notifyActionExtendNow, notifications.DefaultActionIdentifier:
|
||||
// DefaultActionIdentifier is the body-click on platforms with no separate buttons; treat as Extend.
|
||||
go t.runExtendSession()
|
||||
case notifyActionDismiss:
|
||||
go t.dismissSessionWarning()
|
||||
}
|
||||
}
|
||||
|
||||
// buildSessionWarningBody composes the localised notification body from the daemon's metadata.
|
||||
|
||||
@@ -80,13 +80,12 @@ func (c *Client) Connect(host string, port int, username, jwtToken string, ipVer
|
||||
return fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
||||
sshClient, err := nbssh.Handshake(ctx, conn, addr, config)
|
||||
if err != nil {
|
||||
closeWithLog(conn, "connection after handshake error")
|
||||
return fmt.Errorf("SSH handshake: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
c.sshClient = ssh.NewClient(sshConn, chans, reqs)
|
||||
c.sshClient = sshClient
|
||||
logrus.Infof("SSH: Connected to %s", addr)
|
||||
|
||||
return nil
|
||||
@@ -119,57 +118,26 @@ func (c *Client) getAuthMethods(jwtToken string) ([]ssh.AuthMethod, error) {
|
||||
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
|
||||
}
|
||||
|
||||
// StartSession starts an SSH session with PTY
|
||||
// StartSession starts an SSH session with PTY. It holds the client lock for
|
||||
// the whole startup so Close cannot tear the client down mid-setup and the
|
||||
// new session cannot be installed into an already closed client.
|
||||
func (c *Client) StartSession(cols, rows int) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.sshClient == nil {
|
||||
return fmt.Errorf("SSH client not connected")
|
||||
}
|
||||
|
||||
session, err := c.sshClient.NewSession()
|
||||
pty, err := nbssh.StartPTYSession(c.sshClient, cols, rows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create session: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.session = session
|
||||
|
||||
modes := ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
ssh.VINTR: 3,
|
||||
ssh.VQUIT: 28,
|
||||
ssh.VERASE: 127,
|
||||
}
|
||||
|
||||
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
||||
closeWithLog(session, "session after PTY error")
|
||||
return fmt.Errorf("PTY request: %w", err)
|
||||
}
|
||||
|
||||
c.stdin, err = session.StdinPipe()
|
||||
if err != nil {
|
||||
closeWithLog(session, "session after stdin error")
|
||||
return fmt.Errorf("get stdin: %w", err)
|
||||
}
|
||||
|
||||
c.stdout, err = session.StdoutPipe()
|
||||
if err != nil {
|
||||
closeWithLog(session, "session after stdout error")
|
||||
return fmt.Errorf("get stdout: %w", err)
|
||||
}
|
||||
|
||||
c.stderr, err = session.StderrPipe()
|
||||
if err != nil {
|
||||
closeWithLog(session, "session after stderr error")
|
||||
return fmt.Errorf("get stderr: %w", err)
|
||||
}
|
||||
|
||||
if err := session.Shell(); err != nil {
|
||||
closeWithLog(session, "session after shell error")
|
||||
return fmt.Errorf("start shell: %w", err)
|
||||
}
|
||||
c.session = pty.Session
|
||||
c.stdin = pty.Stdin
|
||||
c.stdout = pty.Stdout
|
||||
c.stderr = pty.Stderr
|
||||
|
||||
logrus.Info("SSH: Session started with PTY")
|
||||
return nil
|
||||
|
||||
@@ -49,6 +49,7 @@ type Credential struct {
|
||||
type CredentialPayload struct {
|
||||
Type proto.Body_Type
|
||||
WgListenPort int
|
||||
FiledropPort uint16
|
||||
Credential *Credential
|
||||
RosenpassPubKey []byte
|
||||
RosenpassAddr string
|
||||
@@ -76,6 +77,7 @@ func MarshalCredential(myKey wgtypes.Key, remoteKey string, p CredentialPayload)
|
||||
Type: p.Type,
|
||||
Payload: fmt.Sprintf("%s:%s", p.Credential.UFrag, p.Credential.Pwd),
|
||||
WgListenPort: uint32(p.WgListenPort),
|
||||
FiledropPort: uint32(p.FiledropPort),
|
||||
NetBirdVersion: version.NetbirdVersion(),
|
||||
RosenpassConfig: &proto.RosenpassConfig{
|
||||
RosenpassPubKey: p.RosenpassPubKey,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.26.0
|
||||
// protoc v3.21.12
|
||||
// protoc v7.34.1
|
||||
// source: signalexchange.proto
|
||||
|
||||
package proto
|
||||
@@ -239,6 +239,9 @@ type Body struct {
|
||||
// fallback dial target when DNS resolution of relayServerAddress fails.
|
||||
// SNI/TLS verification still uses relayServerAddress.
|
||||
RelayServerIP []byte `protobuf:"bytes,11,opt,name=relayServerIP,proto3,oneof" json:"relayServerIP,omitempty"`
|
||||
// filedropPort is the sender's file drop listen port when it differs from
|
||||
// the well-known default; 0 (absent) means the default port.
|
||||
FiledropPort uint32 `protobuf:"varint,12,opt,name=filedropPort,proto3" json:"filedropPort,omitempty"`
|
||||
}
|
||||
|
||||
func (x *Body) Reset() {
|
||||
@@ -343,6 +346,13 @@ func (x *Body) GetRelayServerIP() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Body) GetFiledropPort() uint32 {
|
||||
if x != nil {
|
||||
return x.FiledropPort
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Mode indicates a connection mode
|
||||
type Mode struct {
|
||||
state protoimpl.MessageState
|
||||
@@ -466,7 +476,7 @@ var file_signalexchange_proto_rawDesc = []byte{
|
||||
0x52, 0x09, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x04, 0x62,
|
||||
0x6f, 0x64, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x69, 0x67, 0x6e,
|
||||
0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x52,
|
||||
0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xd2, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d,
|
||||
0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xf6, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d,
|
||||
0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x73,
|
||||
0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f,
|
||||
0x64, 0x79, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a,
|
||||
@@ -494,39 +504,41 @@ var file_signalexchange_proto_rawDesc = []byte{
|
||||
0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x29,
|
||||
0x0a, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x18,
|
||||
0x0b, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x02, 0x52, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65,
|
||||
0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70,
|
||||
0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06,
|
||||
0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x44,
|
||||
0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, 0x4f, 0x44, 0x45, 0x10,
|
||||
0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x12, 0x0d,
|
||||
0x0a, 0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x06, 0x42, 0x15, 0x0a,
|
||||
0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64,
|
||||
0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
|
||||
0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76,
|
||||
0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, 0x6f,
|
||||
0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, 0x42,
|
||||
0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, 0x6f,
|
||||
0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a,
|
||||
0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73,
|
||||
0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e,
|
||||
0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53,
|
||||
0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69,
|
||||
0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04,
|
||||
0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63,
|
||||
0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
|
||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65,
|
||||
0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
|
||||
0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f,
|
||||
0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69,
|
||||
0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63,
|
||||
0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e,
|
||||
0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45,
|
||||
0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22,
|
||||
0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
|
||||
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x66, 0x69, 0x6c,
|
||||
0x65, 0x64, 0x72, 0x6f, 0x70, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52,
|
||||
0x0c, 0x66, 0x69, 0x6c, 0x65, 0x64, 0x72, 0x6f, 0x70, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x52, 0x0a,
|
||||
0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00,
|
||||
0x12, 0x0a, 0x0a, 0x06, 0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09,
|
||||
0x43, 0x41, 0x4e, 0x44, 0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d,
|
||||
0x4f, 0x44, 0x45, 0x10, 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45,
|
||||
0x10, 0x05, 0x12, 0x0d, 0x0a, 0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10,
|
||||
0x06, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65,
|
||||
0x72, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73,
|
||||
0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79,
|
||||
0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e,
|
||||
0x0a, 0x04, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74,
|
||||
0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d,
|
||||
0x0a, 0x0f, 0x52, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69,
|
||||
0x67, 0x12, 0x28, 0x0a, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75,
|
||||
0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65,
|
||||
0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72,
|
||||
0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64,
|
||||
0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70,
|
||||
0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01,
|
||||
0x0a, 0x0e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65,
|
||||
0x12, 0x4c, 0x0a, 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61,
|
||||
0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
|
||||
0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67,
|
||||
0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72,
|
||||
0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59,
|
||||
0x0a, 0x0d, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12,
|
||||
0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65,
|
||||
0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
||||
0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e,
|
||||
0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
|
||||
0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -75,6 +75,10 @@ message Body {
|
||||
// fallback dial target when DNS resolution of relayServerAddress fails.
|
||||
// SNI/TLS verification still uses relayServerAddress.
|
||||
optional bytes relayServerIP = 11;
|
||||
|
||||
// filedropPort is the sender's file drop listen port when it differs from
|
||||
// the well-known default; 0 (absent) means the default port.
|
||||
uint32 filedropPort = 12;
|
||||
}
|
||||
|
||||
// Mode indicates a connection mode
|
||||
|
||||
Reference in New Issue
Block a user