mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-21 07:01:30 +02:00
Compare commits
44 Commits
i18n-anony
...
android-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
090f9d8e80 | ||
|
|
faf8619a83 | ||
|
|
eba86a3007 | ||
|
|
3d7b25a02c | ||
|
|
73cffdb702 | ||
|
|
14aab0fc6e | ||
|
|
f06b8c7624 | ||
|
|
ee3aeadf8f | ||
|
|
c28cf2fa61 | ||
|
|
78c95bb8ec | ||
|
|
1aa1f915a2 | ||
|
|
0bb49fa144 | ||
|
|
ceb1719f9a | ||
|
|
2da4512272 | ||
|
|
cfd3a231e2 | ||
|
|
31e558a64d | ||
|
|
f986b33b92 | ||
|
|
16f7e1e148 | ||
|
|
9531c9cf79 | ||
|
|
ba16475ad6 | ||
|
|
fc52cdcedd | ||
|
|
a98ced399e | ||
|
|
b4cc8a16d9 | ||
|
|
3c973c98e3 | ||
|
|
1fcb6d4bed | ||
|
|
7612b4d299 | ||
|
|
ecce1aa2a1 | ||
|
|
53f2518af1 | ||
|
|
71a4276426 | ||
|
|
71bfc73cd1 | ||
|
|
77e7d82d5a | ||
|
|
e3e5a25605 | ||
|
|
a8d2e5b0b2 | ||
|
|
cc0702396c | ||
|
|
6a83476831 | ||
|
|
c4c8e2fe1e | ||
|
|
a33e981c26 | ||
|
|
c1c8ee832e | ||
|
|
9ee5c04687 | ||
|
|
072fa8143b | ||
|
|
8fb3e707af | ||
|
|
902263ac96 | ||
|
|
26f7ed858d | ||
|
|
82e799f095 |
@@ -1,6 +1,6 @@
|
||||
# NetBird Agent Guidelines
|
||||
|
||||
**NetBird** is an open source connectivity platform: a WireGuard®-based overlay
|
||||
**NetBird** is an open-source connectivity platform: a WireGuard®-based overlay
|
||||
network with a control plane. The **agent** (`client/`) runs on user machines as
|
||||
a privileged daemon and manages the WireGuard interface, routing, firewall, and
|
||||
DNS. **Management** (`management/`) is the control plane and REST/gRPC API,
|
||||
|
||||
@@ -479,7 +479,7 @@ go test -race ./client/internal/dns/...
|
||||
|
||||
## Checklist before submitting a PR
|
||||
|
||||
As a critical network service and open source project, we must enforce a few
|
||||
As a critical network service and open-source project, we must enforce a few
|
||||
things before submitting a pull request. The
|
||||
[pull request template](/.github/pull_request_template.md) mirrors this list —
|
||||
fill it in rather than deleting it.
|
||||
|
||||
@@ -130,7 +130,7 @@ In November 2022, NetBird joined the [StartUpSecure program](https://www.forschu
|
||||

|
||||
|
||||
### Acknowledgements
|
||||
We build on open source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
|
||||
We build on open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
|
||||
|
||||
### Legal
|
||||
This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/.
|
||||
|
||||
@@ -14,7 +14,7 @@ Report security issues one of these two ways:
|
||||
on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place.
|
||||
- **Email** — `security@netbird.io`.
|
||||
|
||||
If the finding affects NetBird Cloud or our hosted infrastructure rather than the open source code, email us rather than
|
||||
If the finding affects NetBird Cloud or our hosted infrastructure rather than the open-source code, email us rather than
|
||||
filing a repository report.
|
||||
|
||||
### What to include
|
||||
|
||||
@@ -114,6 +114,11 @@ type Client struct {
|
||||
|
||||
extendMu sync.Mutex
|
||||
extendCancel context.CancelFunc
|
||||
|
||||
// The file drop handle survives engine restarts so the UI keeps one listener
|
||||
// registration and one history view across reconnects. See fileDropFor.
|
||||
fileDropMu sync.Mutex
|
||||
fileDrop *FileDrop
|
||||
}
|
||||
|
||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
|
||||
@@ -152,7 +157,6 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
|
||||
execWorkaround(androidSDKVersion)
|
||||
|
||||
net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket)
|
||||
system.SetIFaceDiscover(iFaceDiscover)
|
||||
return &Client{
|
||||
deviceName: deviceName,
|
||||
uiVersion: uiVersion,
|
||||
@@ -205,6 +209,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
|
||||
c.attachFileDrop(connectClient, cfgFile)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||
// is authenticated again — release the latch Status() reports from. Clear
|
||||
@@ -247,6 +252,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
|
||||
c.attachFileDrop(connectClient, cfgFile)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
164
client/android/filedrop_transfer.go
Normal file
164
client/android/filedrop_transfer.go
Normal file
@@ -0,0 +1,164 @@
|
||||
//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)
|
||||
FileDropEventProgress = int(filedrop.EventProgress)
|
||||
)
|
||||
|
||||
// FileDropListener receives transfer events. Calls arrive on background
|
||||
// goroutines, so implementations must post to the UI thread themselves.
|
||||
type FileDropListener interface {
|
||||
OnFileDropEvent(kind int, transfer *FileDropTransfer)
|
||||
}
|
||||
|
||||
// FileDropFile is one item of a transfer.
|
||||
type FileDropFile struct {
|
||||
Name string
|
||||
Size int64
|
||||
ContentType string
|
||||
IsText bool
|
||||
Text string
|
||||
}
|
||||
|
||||
// FileDropTransfer is one history entry.
|
||||
type FileDropTransfer struct {
|
||||
ID string
|
||||
Outgoing bool
|
||||
PeerKey string
|
||||
PeerName string
|
||||
State int
|
||||
Transferred int64
|
||||
TotalSize int64
|
||||
// Unix milliseconds, so the platform layer can render the time in the
|
||||
// user's own locale and zone rather than parsing a preformatted string.
|
||||
CreatedAtMillis int64
|
||||
UpdatedAtMillis int64
|
||||
// IsText marks a transfer that is a single inline snippet rather than
|
||||
// files, so the UI can drop the size and offer a copy action instead.
|
||||
IsText bool
|
||||
Error string
|
||||
Reason int
|
||||
|
||||
files []*FileDropFile
|
||||
deliveredPaths []string
|
||||
}
|
||||
|
||||
// FileDropTransferArray wraps transfers for gomobile compatibility.
|
||||
type FileDropTransferArray struct {
|
||||
items []*FileDropTransfer
|
||||
}
|
||||
|
||||
// FileCount returns the number of items in the transfer.
|
||||
func (t *FileDropTransfer) FileCount() int {
|
||||
return len(t.files)
|
||||
}
|
||||
|
||||
// GetFile returns the item at index i, or nil when out of range.
|
||||
func (t *FileDropTransfer) GetFile(i int) *FileDropFile {
|
||||
if i < 0 || i >= len(t.files) {
|
||||
return nil
|
||||
}
|
||||
return t.files[i]
|
||||
}
|
||||
|
||||
// DeliveredPaths returns the delivered file paths joined by newlines, so the
|
||||
// platform layer can move them into user-visible storage.
|
||||
func (t *FileDropTransfer) DeliveredPaths() string {
|
||||
return strings.Join(t.deliveredPaths, "\n")
|
||||
}
|
||||
|
||||
// Length returns the number of transfers.
|
||||
func (a *FileDropTransferArray) Length() int {
|
||||
return len(a.items)
|
||||
}
|
||||
|
||||
// Get returns the transfer at index i, or nil when out of range.
|
||||
func (a *FileDropTransferArray) Get(i int) *FileDropTransfer {
|
||||
if i < 0 || i >= len(a.items) {
|
||||
return nil
|
||||
}
|
||||
return a.items[i]
|
||||
}
|
||||
|
||||
func toFileDropTransfer(t filedrop.Transfer) *FileDropTransfer {
|
||||
files := make([]*FileDropFile, 0, len(t.Files))
|
||||
for _, f := range t.Files {
|
||||
files = append(files, &FileDropFile{
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
ContentType: f.ContentType,
|
||||
IsText: f.Kind == filedrop.KindText,
|
||||
Text: f.Text,
|
||||
})
|
||||
}
|
||||
|
||||
return &FileDropTransfer{
|
||||
ID: string(t.ID),
|
||||
Outgoing: t.Direction == filedrop.DirectionSent,
|
||||
PeerKey: string(t.PeerKey),
|
||||
PeerName: t.PeerName,
|
||||
State: int(t.State),
|
||||
Transferred: t.Transferred,
|
||||
TotalSize: t.TotalSize,
|
||||
CreatedAtMillis: unixMillis(t.CreatedAt),
|
||||
UpdatedAtMillis: unixMillis(t.UpdatedAt),
|
||||
IsText: len(t.Files) == 1 && t.Files[0].Kind == filedrop.KindText,
|
||||
Error: t.Error,
|
||||
Reason: int(t.Reason),
|
||||
files: files,
|
||||
deliveredPaths: t.DeliveredPaths,
|
||||
}
|
||||
}
|
||||
|
||||
// unixMillis renders a timestamp for the platform layer, mapping the zero time
|
||||
// to 0 so it reads as "unknown" rather than as 1970.
|
||||
func unixMillis(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.UnixMilli()
|
||||
}
|
||||
@@ -48,6 +48,33 @@ func profileAccountPathFor(configPath string) (string, error) {
|
||||
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
|
||||
}
|
||||
|
||||
// profileLocationFor splits a profile's config path back into the config dir and
|
||||
// the profile ID: <dir>/netbird.cfg is the default profile, while
|
||||
// <dir>/profiles/<id>.json is a named one.
|
||||
func profileLocationFor(configPath string) (string, string, error) {
|
||||
if configPath == "" {
|
||||
return "", "", fmt.Errorf("empty config path")
|
||||
}
|
||||
|
||||
base := filepath.Base(configPath)
|
||||
dir := filepath.Dir(configPath)
|
||||
|
||||
if base == defaultConfigFilename {
|
||||
return dir, profilemanager.DefaultProfileName, nil
|
||||
}
|
||||
|
||||
if filepath.Base(dir) != profilesSubdir {
|
||||
return "", "", fmt.Errorf("config path %q is outside the profiles directory", configPath)
|
||||
}
|
||||
|
||||
id := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", "", fmt.Errorf("config path %q has no valid profile ID", configPath)
|
||||
}
|
||||
|
||||
return filepath.Dir(dir), id, nil
|
||||
}
|
||||
|
||||
// readProfileEmail returns the account email stored for the profile whose config
|
||||
// lives at configPath. A missing or unreadable file yields "", which leaves the
|
||||
// account choice to the IdP.
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestRetryWakesOnNetworkChange(t *testing.T) {
|
||||
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Minute), ns)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, attempts, "network change must cause one immediate retry")
|
||||
assert.Equal(t, 2, attempts)
|
||||
assert.Less(t, time.Since(start), time.Second, "the transition must cut the minute-long sleep short")
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestRetryPermanentError(t *testing.T) {
|
||||
}
|
||||
|
||||
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil)
|
||||
assert.ErrorIs(t, err, sentinel, "permanent errors must stop retries")
|
||||
assert.ErrorIs(t, err, sentinel)
|
||||
}
|
||||
|
||||
func TestRetryNilNetState(t *testing.T) {
|
||||
@@ -59,7 +59,7 @@ func TestRetryNilNetState(t *testing.T) {
|
||||
|
||||
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, attempts, "nil network state must preserve timed retries")
|
||||
assert.Equal(t, 3, attempts)
|
||||
}
|
||||
|
||||
func TestRetryStops(t *testing.T) {
|
||||
@@ -69,7 +69,7 @@ func TestRetryStops(t *testing.T) {
|
||||
}
|
||||
|
||||
err := Retry(context.Background(), operation, &backoff.StopBackOff{}, nil)
|
||||
assert.ErrorIs(t, err, failure, "stop backoff must return the operation error")
|
||||
assert.ErrorIs(t, err, failure)
|
||||
}
|
||||
|
||||
func TestRetryCtxCancelDuringSleep(t *testing.T) {
|
||||
@@ -86,6 +86,6 @@ func TestRetryCtxCancelDuringSleep(t *testing.T) {
|
||||
start := time.Now()
|
||||
err := Retry(ctx, operation, backoff.NewConstantBackOff(time.Minute), netstate.New())
|
||||
|
||||
assert.ErrorIs(t, err, context.Canceled, "context cancellation must stop the retry loop")
|
||||
assert.Less(t, time.Since(start), time.Second, "context cancellation must interrupt backoff sleep")
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
assert.Less(t, time.Since(start), time.Second)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/netip"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
)
|
||||
|
||||
// MockPacketFilter is a mock of PacketFilter interface.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
os "os"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
tun "golang.zx2c4.com/wireguard/tun"
|
||||
)
|
||||
|
||||
|
||||
@@ -53,15 +53,15 @@ func NewProxyBind(bind Bind, mtu uint16) *ProxyBind {
|
||||
return p
|
||||
}
|
||||
|
||||
// AddRelayedConn adds a new connection to the bind.
|
||||
// AddTurnConn adds a new connection to the bind.
|
||||
// endpoint is the NetBird address of the remote peer. The SetEndpoint return with the address what will be used in the
|
||||
// WireGuard configuration.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context is used for proxyToLocal to avoid unnecessary error messages
|
||||
// - nbAddr: The NetBird UDP address of the remote peer, it required to generate fake address
|
||||
// - remoteConn: The established relayed connection to the remote peer
|
||||
func (p *ProxyBind) AddRelayedConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error {
|
||||
// - remoteConn: The established TURN connection to the remote peer
|
||||
func (p *ProxyBind) AddTurnConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error {
|
||||
fakeNetIP, err := fakeAddress(nbAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -30,9 +30,9 @@ type WGEBPFProxy struct {
|
||||
proxyPort int
|
||||
mtu uint16
|
||||
|
||||
ebpfManager ebpfMgr.Manager
|
||||
relayedConnStore map[uint16]net.Conn
|
||||
relayedConnMutex sync.Mutex
|
||||
ebpfManager ebpfMgr.Manager
|
||||
turnConnStore map[uint16]net.Conn
|
||||
turnConnMutex sync.Mutex
|
||||
|
||||
lastUsedPort uint16
|
||||
rawConnIPv4 net.PacketConn
|
||||
@@ -50,7 +50,7 @@ func NewWGEBPFProxy(wgPort int, mtu uint16) *WGEBPFProxy {
|
||||
localWGListenPort: wgPort,
|
||||
mtu: mtu,
|
||||
ebpfManager: ebpf.GetEbpfManagerInstance(),
|
||||
relayedConnStore: make(map[uint16]net.Conn),
|
||||
turnConnStore: make(map[uint16]net.Conn),
|
||||
}
|
||||
return wgProxy
|
||||
}
|
||||
@@ -110,14 +110,14 @@ func (p *WGEBPFProxy) Listen() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRelayedConn add new relayed connection for the proxy
|
||||
func (p *WGEBPFProxy) AddRelayedConn(relayedConn net.Conn) (*net.UDPAddr, error) {
|
||||
wgEndpointPort, err := p.storeRelayedConn(relayedConn)
|
||||
// AddTurnConn add new turn connection for the proxy
|
||||
func (p *WGEBPFProxy) AddTurnConn(turnConn net.Conn) (*net.UDPAddr, error) {
|
||||
wgEndpointPort, err := p.storeTurnConn(turnConn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Infof("relayed conn added to wg proxy store: %s, endpoint port: :%d", relayedConn.RemoteAddr(), wgEndpointPort)
|
||||
log.Infof("turn conn added to wg proxy store: %s, endpoint port: :%d", turnConn.RemoteAddr(), wgEndpointPort)
|
||||
|
||||
wgEndpoint := &net.UDPAddr{
|
||||
IP: net.ParseIP(loopbackAddr),
|
||||
@@ -186,48 +186,48 @@ func (p *WGEBPFProxy) readAndForwardPacket(buf []byte) error {
|
||||
return fmt.Errorf("failed to read UDP packet from WG: %w", err)
|
||||
}
|
||||
|
||||
p.relayedConnMutex.Lock()
|
||||
conn, ok := p.relayedConnStore[uint16(addr.Port)]
|
||||
p.relayedConnMutex.Unlock()
|
||||
p.turnConnMutex.Lock()
|
||||
conn, ok := p.turnConnStore[uint16(addr.Port)]
|
||||
p.turnConnMutex.Unlock()
|
||||
if !ok {
|
||||
if p.ctx.Err() == nil {
|
||||
log.Debugf("relayed conn not found by port because conn already has been closed: %d", addr.Port)
|
||||
log.Debugf("turn conn not found by port because conn already has been closed: %d", addr.Port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := conn.Write(buf[:n]); err != nil {
|
||||
return fmt.Errorf("forward local WG packet (%d) to remote relayed conn: %w", addr.Port, err)
|
||||
return fmt.Errorf("failed to forward local WG packet (%d) to remote turn conn: %w", addr.Port, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *WGEBPFProxy) storeRelayedConn(relayedConn net.Conn) (uint16, error) {
|
||||
p.relayedConnMutex.Lock()
|
||||
defer p.relayedConnMutex.Unlock()
|
||||
func (p *WGEBPFProxy) storeTurnConn(turnConn net.Conn) (uint16, error) {
|
||||
p.turnConnMutex.Lock()
|
||||
defer p.turnConnMutex.Unlock()
|
||||
|
||||
np, err := p.nextFreePort()
|
||||
if err != nil {
|
||||
return np, err
|
||||
}
|
||||
p.relayedConnStore[np] = relayedConn
|
||||
p.turnConnStore[np] = turnConn
|
||||
return np, nil
|
||||
}
|
||||
|
||||
func (p *WGEBPFProxy) removeRelayedConn(relayedConnID uint16) {
|
||||
p.relayedConnMutex.Lock()
|
||||
defer p.relayedConnMutex.Unlock()
|
||||
func (p *WGEBPFProxy) removeTurnConn(turnConnID uint16) {
|
||||
p.turnConnMutex.Lock()
|
||||
defer p.turnConnMutex.Unlock()
|
||||
|
||||
_, ok := p.relayedConnStore[relayedConnID]
|
||||
_, ok := p.turnConnStore[turnConnID]
|
||||
if ok {
|
||||
log.Debugf("remove relayed conn from store by port: %d", relayedConnID)
|
||||
log.Debugf("remove turn conn from store by port: %d", turnConnID)
|
||||
}
|
||||
delete(p.relayedConnStore, relayedConnID)
|
||||
delete(p.turnConnStore, turnConnID)
|
||||
}
|
||||
|
||||
func (p *WGEBPFProxy) nextFreePort() (uint16, error) {
|
||||
if len(p.relayedConnStore) == 65535 {
|
||||
return 0, fmt.Errorf("reached maximum relayed connection numbers")
|
||||
if len(p.turnConnStore) == 65535 {
|
||||
return 0, fmt.Errorf("reached maximum turn connection numbers")
|
||||
}
|
||||
generatePort:
|
||||
if p.lastUsedPort == 65535 {
|
||||
@@ -236,7 +236,7 @@ generatePort:
|
||||
p.lastUsedPort++
|
||||
}
|
||||
|
||||
if _, ok := p.relayedConnStore[p.lastUsedPort]; ok {
|
||||
if _, ok := p.turnConnStore[p.lastUsedPort]; ok {
|
||||
goto generatePort
|
||||
}
|
||||
return p.lastUsedPort, nil
|
||||
|
||||
@@ -9,32 +9,32 @@ import (
|
||||
func TestWGEBPFProxy_connStore(t *testing.T) {
|
||||
wgProxy := NewWGEBPFProxy(1, 1280)
|
||||
|
||||
p, _ := wgProxy.storeRelayedConn(nil)
|
||||
p, _ := wgProxy.storeTurnConn(nil)
|
||||
if p != 1 {
|
||||
t.Errorf("invalid initial port: %d", wgProxy.lastUsedPort)
|
||||
}
|
||||
|
||||
numOfConns := 10
|
||||
for i := 0; i < numOfConns; i++ {
|
||||
p, _ = wgProxy.storeRelayedConn(nil)
|
||||
p, _ = wgProxy.storeTurnConn(nil)
|
||||
}
|
||||
if p != uint16(numOfConns)+1 {
|
||||
t.Errorf("invalid last used port: %d, expected: %d", p, numOfConns+1)
|
||||
}
|
||||
if len(wgProxy.relayedConnStore) != numOfConns+1 {
|
||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), numOfConns+1)
|
||||
if len(wgProxy.turnConnStore) != numOfConns+1 {
|
||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), numOfConns+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWGEBPFProxy_portCalculation_overflow(t *testing.T) {
|
||||
wgProxy := NewWGEBPFProxy(1, 1280)
|
||||
|
||||
_, _ = wgProxy.storeRelayedConn(nil)
|
||||
_, _ = wgProxy.storeTurnConn(nil)
|
||||
wgProxy.lastUsedPort = 65535
|
||||
p, _ := wgProxy.storeRelayedConn(nil)
|
||||
p, _ := wgProxy.storeTurnConn(nil)
|
||||
|
||||
if len(wgProxy.relayedConnStore) != 2 {
|
||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), 2)
|
||||
if len(wgProxy.turnConnStore) != 2 {
|
||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), 2)
|
||||
}
|
||||
|
||||
if p != 2 {
|
||||
@@ -46,11 +46,11 @@ func TestWGEBPFProxy_portCalculation_maxConn(t *testing.T) {
|
||||
wgProxy := NewWGEBPFProxy(1, 1280)
|
||||
|
||||
for i := 0; i < 65535; i++ {
|
||||
_, _ = wgProxy.storeRelayedConn(nil)
|
||||
_, _ = wgProxy.storeTurnConn(nil)
|
||||
}
|
||||
|
||||
_, err := wgProxy.storeRelayedConn(nil)
|
||||
_, err := wgProxy.storeTurnConn(nil)
|
||||
if err == nil {
|
||||
t.Errorf("invalid relayed conn store calculation")
|
||||
t.Errorf("invalid turn conn store calculation")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,10 +121,10 @@ func NewProxyWrapper(proxy *WGEBPFProxy) *ProxyWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProxyWrapper) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||
addr, err := p.wgeBPFProxy.AddRelayedConn(remoteConn)
|
||||
func (p *ProxyWrapper) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||
addr, err := p.wgeBPFProxy.AddTurnConn(remoteConn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add relayed conn: %w", err)
|
||||
return fmt.Errorf("add turn conn: %w", err)
|
||||
}
|
||||
|
||||
headers, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, addr)
|
||||
@@ -252,7 +252,7 @@ func (p *ProxyWrapper) CloseConn() error {
|
||||
}
|
||||
|
||||
func (p *ProxyWrapper) proxyToLocal(ctx context.Context) {
|
||||
defer p.wgeBPFProxy.removeRelayedConn(uint16(p.wgRelayedEndpointAddr.Port))
|
||||
defer p.wgeBPFProxy.removeTurnConn(uint16(p.wgRelayedEndpointAddr.Port))
|
||||
|
||||
buf := make([]byte, p.wgeBPFProxy.mtu+bufsize.WGBufferOverhead)
|
||||
for {
|
||||
@@ -273,7 +273,7 @@ func (p *ProxyWrapper) proxyToLocal(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Errorf("failed to write out relayed pkg to local conn: %v", err)
|
||||
log.Errorf("failed to write out turn pkg to local conn: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,7 @@ func (p *ProxyWrapper) readFromRemote(ctx context.Context, buf []byte) (int, err
|
||||
}
|
||||
p.closeListener.Notify()
|
||||
if !errors.Is(err, io.EOF) {
|
||||
log.Errorf("failed to read from relayed conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err)
|
||||
log.Errorf("failed to read from turn conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// Proxy is a transfer layer between the relayed connection and the WireGuard
|
||||
type Proxy interface {
|
||||
AddRelayedConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error
|
||||
AddTurnConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error
|
||||
EndpointAddr() *net.UDPAddr // EndpointAddr returns the address of the WireGuard peer endpoint
|
||||
Work() // Work start or resume the proxy
|
||||
Pause() // Pause to forward the packages from remote connection to WireGuard. The opposite way still works.
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestProxyCloseByRemoteConn(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
addr, _ := net.ResolveUDPAddr("udp", "100.108.135.221:51892")
|
||||
relayedConn := newMockConn()
|
||||
err := tt.proxy.AddRelayedConn(ctx, addr, relayedConn)
|
||||
err := tt.proxy.AddTurnConn(ctx, addr, relayedConn)
|
||||
if err != nil {
|
||||
t.Errorf("error: %v", err)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func redirectTraffic(t *testing.T, proxy Proxy, wgPort int, endPointAddr *net.UD
|
||||
_ = relayedServer.Close()
|
||||
}()
|
||||
|
||||
if err := proxy.AddRelayedConn(context.Background(), endPointAddr, relayedConn); err != nil {
|
||||
if err := proxy.AddTurnConn(context.Background(), endPointAddr, relayedConn); err != nil {
|
||||
t.Errorf("error: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
|
||||
@@ -119,9 +119,9 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint *
|
||||
}
|
||||
defer relayConn.Close()
|
||||
|
||||
// Add relayed connection to proxy
|
||||
if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil {
|
||||
t.Fatalf("failed to add relayed connection: %v", err)
|
||||
// Add TURN connection to proxy
|
||||
if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil {
|
||||
t.Fatalf("failed to add TURN connection: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := proxy.CloseConn(); err != nil {
|
||||
@@ -304,8 +304,8 @@ func TestRedirectAs_Multiple_Switches(t *testing.T) {
|
||||
Port: 38746,
|
||||
}
|
||||
|
||||
if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil {
|
||||
t.Fatalf("failed to add relayed connection: %v", err)
|
||||
if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil {
|
||||
t.Fatalf("failed to add TURN connection: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := proxy.CloseConn(); err != nil {
|
||||
|
||||
@@ -51,12 +51,12 @@ func NewWGUDPProxy(wgPort int, mtu uint16) *WGUDPProxy {
|
||||
return p
|
||||
}
|
||||
|
||||
// AddRelayedConn dials the local WireGuard port and stores the relayed connection.
|
||||
// AddTurnConn
|
||||
// The provided Context must be non-nil. If the context expires before
|
||||
// the connection is complete, an error is returned. Once successfully
|
||||
// connected, any expiration of the context will not affect the
|
||||
// connection.
|
||||
func (p *WGUDPProxy) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||
func (p *WGUDPProxy) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||
dialer := net.Dialer{}
|
||||
localConn, err := dialer.DialContext(ctx, "udp", fmt.Sprintf(":%d", p.localWGListenPort))
|
||||
if err != nil {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ package mocks
|
||||
import (
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
@@ -66,10 +67,11 @@ type ConnectClient struct {
|
||||
config *profilemanager.Config
|
||||
statusRecorder *peer.Status
|
||||
|
||||
engine *Engine
|
||||
engineMutex sync.Mutex
|
||||
clientMetrics *metrics.ClientMetrics
|
||||
updateManager *updater.Manager
|
||||
engine *Engine
|
||||
engineMutex sync.Mutex
|
||||
clientMetrics *metrics.ClientMetrics
|
||||
updateManager *updater.Manager
|
||||
fileDropManager *filedrop.Manager
|
||||
|
||||
persistSyncResponse bool
|
||||
|
||||
@@ -125,6 +127,12 @@ func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
|
||||
c.updateManager = um
|
||||
}
|
||||
|
||||
// SetFileDropManager hands the engine the active profile's file drop manager, so
|
||||
// the transfer receiver starts and stops with the tunnel. Must be set before Run.
|
||||
func (c *ConnectClient) SetFileDropManager(m *filedrop.Manager) {
|
||||
c.fileDropManager = m
|
||||
}
|
||||
|
||||
// Run with main logic.
|
||||
func (c *ConnectClient) Run(runningChan chan struct{}, logPath string) error {
|
||||
if androidRunOverride != nil {
|
||||
@@ -463,6 +471,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
UpdateManager: c.updateManager,
|
||||
ClientMetrics: c.clientMetrics,
|
||||
MetricsCtx: c.ctx,
|
||||
FileDrop: c.fileDropManager,
|
||||
NetState: c.netState,
|
||||
}, mobileDependency)
|
||||
engine.SetSyncResponsePersistence(c.persistSyncResponse)
|
||||
|
||||
@@ -35,8 +35,6 @@ var (
|
||||
// exported so a diagnostic reader reports the same locations that are written.
|
||||
const (
|
||||
// NRPTKeyPrefix starts the name of every NRPT rule key this client creates.
|
||||
// Older versions used different layouts under the same prefix: a single
|
||||
// unsuffixed key, then one key per domain, now one key per batch of domains.
|
||||
NRPTKeyPrefix = "NetBird-Match"
|
||||
|
||||
// DNSPolicyConfigRoot holds the NRPT rules of the local policy store.
|
||||
@@ -91,6 +89,7 @@ type registryConfigurator struct {
|
||||
guid string
|
||||
routingAll bool
|
||||
gpo bool
|
||||
nrptEntryCount int
|
||||
origNameservers []netip.Addr
|
||||
}
|
||||
|
||||
@@ -323,9 +322,14 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager
|
||||
}
|
||||
|
||||
if len(matchDomains) != 0 {
|
||||
if err := r.addDNSMatchPolicy(matchDomains, config.ServerIP); err != nil {
|
||||
count, err := r.addDNSMatchPolicy(matchDomains, config.ServerIP)
|
||||
// Update count even on error to ensure cleanup covers partially created rules
|
||||
r.nrptEntryCount = count
|
||||
if err != nil {
|
||||
return fmt.Errorf("add dns match policy: %w", err)
|
||||
}
|
||||
} else {
|
||||
r.nrptEntryCount = 0
|
||||
}
|
||||
|
||||
r.updateState(stateManager)
|
||||
@@ -341,8 +345,9 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager
|
||||
|
||||
func (r *registryConfigurator) updateState(stateManager *statemanager.Manager) {
|
||||
if err := stateManager.UpdateState(&ShutdownState{
|
||||
Guid: r.guid,
|
||||
GPO: r.gpo,
|
||||
Guid: r.guid,
|
||||
GPO: r.gpo,
|
||||
NRPTEntryCount: r.nrptEntryCount,
|
||||
}); err != nil {
|
||||
log.Errorf("failed to update shutdown state: %s", err)
|
||||
}
|
||||
@@ -357,7 +362,7 @@ func (r *registryConfigurator) addDNSSetupForAll(ip netip.Addr) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) error {
|
||||
func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) (int, error) {
|
||||
// if the gpo key is present, we need to put our DNS settings there, otherwise our config might be ignored
|
||||
// see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/8cc31cb9-20cb-4140-9e85-3e08703b4745
|
||||
|
||||
@@ -374,17 +379,19 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr
|
||||
gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, ruleIndex)
|
||||
|
||||
if err := r.configureDNSPolicy(localPath, batchDomains, ip); err != nil {
|
||||
return fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err)
|
||||
return ruleIndex, fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err)
|
||||
}
|
||||
|
||||
// Increment immediately so the caller's cleanup path knows about this rule
|
||||
ruleIndex++
|
||||
|
||||
if r.gpo {
|
||||
if err := r.configureDNSPolicy(gpoPath, batchDomains, ip); err != nil {
|
||||
return fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex, err)
|
||||
return ruleIndex, fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex-1, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("added NRPT rule %d with %d domains", ruleIndex, len(batchDomains))
|
||||
ruleIndex++
|
||||
log.Debugf("added NRPT rule %d with %d domains", ruleIndex-1, len(batchDomains))
|
||||
}
|
||||
|
||||
if r.gpo {
|
||||
@@ -394,7 +401,7 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr
|
||||
}
|
||||
|
||||
log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains))
|
||||
return nil
|
||||
return ruleIndex, nil
|
||||
}
|
||||
|
||||
func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error {
|
||||
@@ -527,28 +534,28 @@ func (r *registryConfigurator) restoreHostDNS() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeDNSMatchPolicies deletes every NRPT rule this client may have created,
|
||||
// from the local and the GPO policy store. The rules are found by enumerating
|
||||
// the registry, the only authoritative record of what was written. Cleanup must
|
||||
// not depend on a rule count: the in-memory one is scoped to a single
|
||||
// registryConfigurator and the persisted one is deleted on every clean
|
||||
// disconnect, and a rule left behind keeps resolving names over an interface
|
||||
// that is gone, until reboot discards the volatile key.
|
||||
func (r *registryConfigurator) removeDNSMatchPolicies() error {
|
||||
var merr *multierror.Error
|
||||
|
||||
for _, root := range []string{DNSPolicyConfigRoot, GPODNSPolicyConfigRoot} {
|
||||
names, err := listNRPTRuleKeys(root)
|
||||
if err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("list rule keys under %s: %w", root, err))
|
||||
continue
|
||||
// Try to remove the base entries (for backward compatibility)
|
||||
if err := removeRegistryKeyFromDNSPolicyConfig(dnsPolicyConfigMatchPath); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove local base entry: %w", err))
|
||||
}
|
||||
|
||||
if err := removeRegistryKeyFromDNSPolicyConfig(gpoDnsPolicyConfigMatchPath); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove GPO base entry: %w", err))
|
||||
}
|
||||
|
||||
for i := 0; i < r.nrptEntryCount; i++ {
|
||||
localPath := fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)
|
||||
gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, i)
|
||||
|
||||
if err := removeRegistryKeyFromDNSPolicyConfig(localPath); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove local entry %d: %w", i, err))
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
path := root + `\` + name
|
||||
if err := removeRegistryKeyFromDNSPolicyConfig(path); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove entry %s: %w", path, err))
|
||||
}
|
||||
if err := removeRegistryKeyFromDNSPolicyConfig(gpoPath); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove GPO entry %d: %w", i, err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,39 +570,6 @@ func (r *registryConfigurator) restoreUncleanShutdownDNS() error {
|
||||
return r.restoreHostDNS()
|
||||
}
|
||||
|
||||
// listNRPTRuleKeys returns the names of our NRPT rule keys under a policy store
|
||||
// root. An absent root holds nothing to clean up, which is the normal state of
|
||||
// the GPO store on a machine without DNS Client policy.
|
||||
func listNRPTRuleKeys(root string) ([]string, error) {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS)
|
||||
switch {
|
||||
case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
|
||||
// the GPO store is absent on a machine without DNS client policy
|
||||
log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", root)
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
// any other failure has to reach the caller: reporting no rules would
|
||||
// report a successful cleanup while leaving the rules in place
|
||||
return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err)
|
||||
}
|
||||
defer closer(k)
|
||||
|
||||
names, err := k.ReadSubKeyNames(-1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read subkey names: %w", err)
|
||||
}
|
||||
|
||||
var ruleKeys []string
|
||||
for _, name := range names {
|
||||
// registry key names are case insensitive
|
||||
if strings.HasPrefix(strings.ToLower(name), strings.ToLower(NRPTKeyPrefix)) {
|
||||
ruleKeys = append(ruleKeys, name)
|
||||
}
|
||||
}
|
||||
|
||||
return ruleKeys, nil
|
||||
}
|
||||
|
||||
func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
|
||||
|
||||
// Create a test interface registry key so updateSearchDomains doesn't fail
|
||||
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
|
||||
interfacePath := InterfaceConfigPath + `\` + testGUID
|
||||
interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID
|
||||
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create test interface registry key")
|
||||
testKey.Close()
|
||||
@@ -56,7 +56,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify 3 NRPT rules exist
|
||||
assert.Equal(t, 3, countNRPTRuleKeys(t), "Should create 3 NRPT rules for 125 domains")
|
||||
assert.Equal(t, 3, cfg.nrptEntryCount, "Should create 3 NRPT rules for 125 domains")
|
||||
for i := 0; i < 3; i++ {
|
||||
exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i))
|
||||
require.NoError(t, err)
|
||||
@@ -81,7 +81,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify first 2 NRPT rules exist
|
||||
assert.Equal(t, 2, countNRPTRuleKeys(t), "Should create 2 NRPT rules for 75 domains")
|
||||
assert.Equal(t, 2, cfg.nrptEntryCount, "Should create 2 NRPT rules for 75 domains")
|
||||
for i := 0; i < 2; i++ {
|
||||
exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i))
|
||||
require.NoError(t, err)
|
||||
@@ -106,65 +106,9 @@ func registryKeyExists(path string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TestNRPTCleanupWithoutRuleCount verifies that rules written by a previous run
|
||||
// are removed by a configurator that has no record of how many there are: an
|
||||
// unclean exit loses the in-memory count and a clean disconnect deletes the
|
||||
// persisted one, so cleanup cannot depend on either.
|
||||
func TestNRPTCleanupWithoutRuleCount(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping registry integration test in short mode")
|
||||
}
|
||||
|
||||
defer cleanupRegistryKeys(t)
|
||||
cleanupRegistryKeys(t)
|
||||
|
||||
testIP := netip.MustParseAddr("100.64.0.1")
|
||||
|
||||
// 75 domains produce two indexed rules, as the current layout does
|
||||
domains := make([]string, 75)
|
||||
for i := range domains {
|
||||
domains[i] = fmt.Sprintf(".domain%d.com", i+1)
|
||||
}
|
||||
|
||||
previousRun := ®istryConfigurator{}
|
||||
require.NoError(t, previousRun.addDNSMatchPolicy(domains, testIP))
|
||||
|
||||
// the unsuffixed key an older version would have written
|
||||
require.NoError(t, previousRun.configureDNSPolicy(dnsPolicyConfigMatchPath, []string{".legacy.example.com"}, testIP))
|
||||
|
||||
// a policy owned by someone else, which cleanup must not touch
|
||||
foreignPath := DNSPolicyConfigRoot + `\DnsPolicyConfigTestForeign`
|
||||
foreignKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, foreignPath, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create foreign policy key")
|
||||
foreignKey.Close()
|
||||
defer func() {
|
||||
_ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignPath)
|
||||
}()
|
||||
|
||||
require.Equal(t, 3, countNRPTRuleKeys(t), "Should have two indexed rules and the legacy one")
|
||||
|
||||
// a configurator that never applied a DNS config, as one built after a
|
||||
// restart or from a shutdown state without a count is
|
||||
freshRun := ®istryConfigurator{}
|
||||
require.NoError(t, freshRun.removeDNSMatchPolicies())
|
||||
|
||||
assert.Equal(t, 0, countNRPTRuleKeys(t), "Should remove every rule left by the previous run")
|
||||
|
||||
exists, err := registryKeyExists(foreignPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Should not remove a policy that is not ours")
|
||||
}
|
||||
|
||||
func countNRPTRuleKeys(t *testing.T) int {
|
||||
t.Helper()
|
||||
|
||||
names, err := listNRPTRuleKeys(DNSPolicyConfigRoot)
|
||||
require.NoError(t, err, "Should list NRPT rule keys")
|
||||
return len(names)
|
||||
}
|
||||
|
||||
func cleanupRegistryKeys(*testing.T) {
|
||||
cfg := ®istryConfigurator{}
|
||||
// Clean up more entries to account for batching tests with many domains
|
||||
cfg := ®istryConfigurator{nrptEntryCount: 20}
|
||||
_ = cfg.removeDNSMatchPolicies()
|
||||
}
|
||||
|
||||
@@ -181,7 +125,7 @@ func TestNRPTDomainBatching(t *testing.T) {
|
||||
|
||||
// Create a test interface registry key so updateSearchDomains doesn't fail
|
||||
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
|
||||
interfacePath := InterfaceConfigPath + `\` + testGUID
|
||||
interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID
|
||||
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create test interface registry key")
|
||||
testKey.Close()
|
||||
@@ -249,7 +193,7 @@ func TestNRPTDomainBatching(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify that exactly expectedRuleCount rules were created
|
||||
assert.Equal(t, tc.expectedRuleCount, countNRPTRuleKeys(t),
|
||||
assert.Equal(t, tc.expectedRuleCount, cfg.nrptEntryCount,
|
||||
"Should create %d NRPT rules for %d domains", tc.expectedRuleCount, tc.domainCount)
|
||||
|
||||
// Verify all expected rules exist
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
@@ -5,8 +5,9 @@ import (
|
||||
)
|
||||
|
||||
type ShutdownState struct {
|
||||
Guid string
|
||||
GPO bool
|
||||
Guid string
|
||||
GPO bool
|
||||
NRPTEntryCount int
|
||||
}
|
||||
|
||||
func (s *ShutdownState) Name() string {
|
||||
@@ -15,8 +16,9 @@ func (s *ShutdownState) Name() string {
|
||||
|
||||
func (s *ShutdownState) Cleanup() error {
|
||||
manager := ®istryConfigurator{
|
||||
guid: s.Guid,
|
||||
gpo: s.GPO,
|
||||
guid: s.Guid,
|
||||
gpo: s.GPO,
|
||||
nrptEntryCount: s.NRPTEntryCount,
|
||||
}
|
||||
|
||||
if err := manager.restoreUncleanShutdownDNS(); err != nil {
|
||||
|
||||
@@ -2,21 +2,17 @@ package ebpf
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/cilium/ebpf/link"
|
||||
"github.com/cilium/ebpf/rlimit"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ebpf/manager"
|
||||
)
|
||||
|
||||
const (
|
||||
xdpProgName = "nb_xdp_prog"
|
||||
|
||||
mapKeyFeatures uint32 = 0
|
||||
|
||||
featureFlagWGProxy = 0b00000001
|
||||
@@ -72,50 +68,21 @@ func (tf *GeneralManager) loadXdp() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// lo has no native XDP, so the program runs in generic mode. Unless it
|
||||
// declares multi-buffer support the kernel must linearize every non-linear
|
||||
// skb before running it. Loopback packets are up to 64 KB, so that is a
|
||||
// contiguous GFP_ATOMIC allocation per packet, and when it fails the packet
|
||||
// is dropped before the program runs, stalling local TCP connections.
|
||||
// Multi-buffer XDP in generic mode requires kernel 6.3, so fall back to a
|
||||
// plain attach when the kernel rejects it.
|
||||
err = tf.attachXdp(iFace.Index, true)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
log.Debugf("failed to attach multi-buffer xdp program, retrying without it: %s", err)
|
||||
|
||||
return tf.attachXdp(iFace.Index, false)
|
||||
}
|
||||
|
||||
func (tf *GeneralManager) attachXdp(iFaceIndex int, multiBuffer bool) error {
|
||||
spec, err := loadBpf()
|
||||
// load pre-compiled programs into the kernel.
|
||||
err = loadBpfObjects(&tf.bpfObjs, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load bpf spec: %w", err)
|
||||
}
|
||||
|
||||
if multiBuffer {
|
||||
prog, ok := spec.Programs[xdpProgName]
|
||||
if !ok {
|
||||
return fmt.Errorf("program %s not found in bpf spec", xdpProgName)
|
||||
}
|
||||
prog.Flags |= unix.BPF_F_XDP_HAS_FRAGS
|
||||
}
|
||||
|
||||
if err := spec.LoadAndAssign(&tf.bpfObjs, nil); err != nil {
|
||||
return fmt.Errorf("load bpf objects: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
tf.link, err = link.AttachXDP(link.XDPOptions{
|
||||
Program: tf.bpfObjs.NbXdpProg,
|
||||
Interface: iFaceIndex,
|
||||
Interface: iFace.Index,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if closeErr := tf.bpfObjs.Close(); closeErr != nil {
|
||||
log.Debugf("failed to close bpf objects after xdp attach error: %s", closeErr)
|
||||
}
|
||||
_ = tf.bpfObjs.Close()
|
||||
tf.link = nil
|
||||
return fmt.Errorf("attach xdp: %w", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import (
|
||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||
"github.com/netbirdio/netbird/client/internal/dnsfwd"
|
||||
"github.com/netbirdio/netbird/client/internal/expose"
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
"github.com/netbirdio/netbird/client/internal/ingressgw"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
@@ -182,6 +183,7 @@ type EngineServices struct {
|
||||
UpdateManager *updater.Manager
|
||||
ClientMetrics *metrics.ClientMetrics
|
||||
MetricsCtx context.Context
|
||||
FileDrop *filedrop.Manager
|
||||
// NetState gates the reconnection loops on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
NetState *netstate.State
|
||||
@@ -244,6 +246,10 @@ type Engine struct {
|
||||
|
||||
sshServer sshServer
|
||||
|
||||
fileDrop *filedrop.Manager
|
||||
fileDropRunning bool
|
||||
fileDropPort uint16
|
||||
|
||||
statusRecorder *peer.Status
|
||||
|
||||
firewall firewallManager.Manager
|
||||
@@ -359,6 +365,7 @@ func NewEngine(
|
||||
metricsCtx: services.MetricsCtx,
|
||||
updateManager: services.UpdateManager,
|
||||
syncStoreDir: config.StateDir,
|
||||
fileDrop: services.FileDrop,
|
||||
}
|
||||
// sessionWatcher keeps the SubscribeStatus consumers in sync with the
|
||||
// session expiry deadline. Deadline-change ticks come for free via
|
||||
@@ -424,6 +431,8 @@ func (e *Engine) stopLocked() {
|
||||
log.Warnf("failed to stop SSH server: %v", err)
|
||||
}
|
||||
|
||||
e.stopFileDrop()
|
||||
|
||||
e.cleanupSSHConfig()
|
||||
|
||||
if e.ingressGatewayMgr != nil {
|
||||
@@ -1302,6 +1311,8 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
e.startFileDrop()
|
||||
|
||||
state := e.statusRecorder.GetLocalPeerState()
|
||||
state.IP = e.wgInterface.Address().String()
|
||||
state.IPv6 = e.wgInterface.Address().IPv6String()
|
||||
@@ -1970,6 +1981,8 @@ func (e *Engine) receiveSignalEvents() error {
|
||||
return err
|
||||
}
|
||||
|
||||
e.recordFiledropPort(msg.Key, msg.GetBody().GetFiledropPort())
|
||||
|
||||
log.Debugf("receiveMSG: took %s to get lock for peer %s with session id %s", gotLock, msg.Key, offerAnswer.SessionID)
|
||||
|
||||
if msg.Body.Type == sProto.Body_OFFER {
|
||||
@@ -2449,6 +2462,8 @@ func (e *Engine) GetWgV6Addr() netip.Addr {
|
||||
return e.wgInterface.Address().IPv6
|
||||
}
|
||||
|
||||
// RenewTun swaps the tunnel device for the one behind fd, which the platform
|
||||
// hands over whenever it re-establishes the interface.
|
||||
func (e *Engine) RenewTun(fd int) error {
|
||||
e.syncMsgMux.Lock()
|
||||
wgInterface := e.wgInterface
|
||||
@@ -2458,7 +2473,12 @@ func (e *Engine) RenewTun(fd int) error {
|
||||
return fmt.Errorf("wireguard interface not initialized")
|
||||
}
|
||||
|
||||
return wgInterface.RenewTun(fd)
|
||||
if err := wgInterface.RenewTun(fd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.restartFileDrop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag
|
||||
|
||||
143
client/internal/engine_filedrop.go
Normal file
143
client/internal/engine_filedrop.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
type filedropResolver struct {
|
||||
status *peer.Status
|
||||
}
|
||||
|
||||
// ResolvePeer implements filedrop.PeerResolver.
|
||||
func (r filedropResolver) ResolvePeer(addr netip.Addr) (filedrop.PeerKey, string, bool) {
|
||||
state, ok := r.status.PeerStateByIP(addr.String())
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return filedrop.PeerKey(state.PubKey), state.FQDN, true
|
||||
}
|
||||
|
||||
func (e *Engine) startFileDrop() {
|
||||
if e.fileDrop == nil || e.fileDropRunning || e.wgInterface == nil {
|
||||
return
|
||||
}
|
||||
if e.config.BlockInbound {
|
||||
log.Info("file drop receiver is disabled because inbound connections are blocked")
|
||||
e.setFileDropTunnel()
|
||||
return
|
||||
}
|
||||
|
||||
wgAddr := e.wgInterface.Address()
|
||||
addr := netip.AddrPortFrom(wgAddr.IP, filedrop.Port)
|
||||
resolver := filedropResolver{status: e.statusRecorder}
|
||||
|
||||
netstackNet := e.wgInterface.GetNet()
|
||||
if err := e.fileDrop.StartReceiver(e.ctx, addr, netstackNet, resolver); err != nil {
|
||||
log.Errorf("failed to start file drop receiver: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
bound := e.fileDrop.ReceiverPort()
|
||||
if bound == 0 {
|
||||
bound = filedrop.Port
|
||||
}
|
||||
e.fileDropPort = bound
|
||||
|
||||
if v6 := wgAddr.IPv6; v6.IsValid() {
|
||||
if err := e.fileDrop.AddReceiverListener(e.ctx, netip.AddrPortFrom(v6, bound)); err != nil {
|
||||
log.Warnf("failed to add IPv6 file drop listener: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if netstackNet != nil {
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
RegisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.RegisterNetstackService(nftypes.TCP, bound)
|
||||
}
|
||||
}
|
||||
|
||||
if bound != filedrop.Port {
|
||||
e.signaler.SetFiledropPort(bound)
|
||||
}
|
||||
|
||||
e.setFileDropTunnel()
|
||||
e.fileDropRunning = true
|
||||
}
|
||||
|
||||
// recordFiledropPort stores the file drop port a peer advertised over signaling;
|
||||
// a value that does not fit a port is treated as the default.
|
||||
func (e *Engine) recordFiledropPort(peerKey string, port uint32) {
|
||||
if e.fileDrop == nil {
|
||||
return
|
||||
}
|
||||
if port > 65535 {
|
||||
port = 0
|
||||
}
|
||||
e.fileDrop.Ports().Set(filedrop.PeerKey(peerKey), uint16(port))
|
||||
}
|
||||
|
||||
func (e *Engine) setFileDropTunnel() {
|
||||
var dial filedrop.DialFunc
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
dial = func(ctx context.Context, _, addr string) (net.Conn, error) {
|
||||
addrPort, err := netip.ParseAddrPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return netstackNet.DialContextTCPAddrPort(ctx, addrPort)
|
||||
}
|
||||
} else {
|
||||
dialer := &net.Dialer{}
|
||||
dial = dialer.DialContext
|
||||
}
|
||||
|
||||
e.fileDrop.SetTunnel(dial, e.statusRecorder.GetLocalPeerState().FQDN)
|
||||
}
|
||||
|
||||
// restartFileDrop rebinds the receiver after the platform replaced the tunnel
|
||||
// device. The listeners are bound to the overlay address of the interface being
|
||||
// swapped out and do not survive it: Android renews the tun on every route
|
||||
// change, which leaves the IPv4 listener dead with accept4: invalid argument.
|
||||
func (e *Engine) restartFileDrop() {
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
|
||||
if e.fileDrop == nil || !e.fileDropRunning || e.wgInterface == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.stopFileDrop()
|
||||
e.startFileDrop()
|
||||
}
|
||||
|
||||
func (e *Engine) stopFileDrop() {
|
||||
if e.fileDrop == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if e.fileDropRunning {
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
UnregisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.UnregisterNetstackService(nftypes.TCP, e.fileDropPort)
|
||||
}
|
||||
}
|
||||
e.signaler.SetFiledropPort(0)
|
||||
}
|
||||
|
||||
if err := e.fileDrop.StopReceiver(); err != nil {
|
||||
log.Warnf("failed to stop file drop receiver: %v", err)
|
||||
}
|
||||
e.fileDropRunning = false
|
||||
e.fileDropPort = 0
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
439
client/internal/filedrop/client.go
Normal file
439
client/internal/filedrop/client.go
Normal file
@@ -0,0 +1,439 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
// A refusal and a vanished offer are answers, not transport
|
||||
// hiccups: the receiver has withdrawn, and retrying only keeps
|
||||
// pushing bytes at someone who said no.
|
||||
if errors.Is(err, ErrNotAccepted) || errors.Is(err, ErrOfferNotFound) {
|
||||
return 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.StatusNotFound {
|
||||
return ErrOfferNotFound
|
||||
}
|
||||
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})
|
||||
}
|
||||
667
client/internal/filedrop/manager.go
Normal file
667
client/internal/filedrop/manager.go
Normal file
@@ -0,0 +1,667 @@
|
||||
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 one of them, thinned to a few reports a second
|
||||
// by the reader that raises it; see progressInterval.
|
||||
const (
|
||||
EventOffer EventKind = iota
|
||||
EventCompleted
|
||||
EventFailed
|
||||
EventWithdrawn
|
||||
EventProgress
|
||||
)
|
||||
|
||||
// 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().Revoke(id); 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)
|
||||
m.emit(EventProgress, m.transferOf(transfer.ID))
|
||||
}
|
||||
|
||||
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 {
|
||||
// Withdrawn mid-transfer reads the same way as refused up front: the
|
||||
// receiver said no, which is an answer rather than a failure.
|
||||
case errors.Is(err, ErrDeclined), errors.Is(err, ErrNotAccepted),
|
||||
errors.Is(err, ErrOfferNotFound):
|
||||
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)
|
||||
m.emit(EventProgress, m.transferOf(offer.ID))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
306
client/internal/filedrop/offer.go
Normal file
306
client/internal/filedrop/offer.go
Normal file
@@ -0,0 +1,306 @@
|
||||
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
|
||||
}
|
||||
|
||||
// Revoke withdraws consent for an offer whatever it has already answered, so an
|
||||
// upload in flight can be stopped. Decide only moves an offer out of Pending: a
|
||||
// transfer that is already running has no way back through it.
|
||||
func (s *OfferStore) Revoke(id OfferID) (Offer, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, ok := s.offers[id]
|
||||
if !ok {
|
||||
return Offer{}, false
|
||||
}
|
||||
|
||||
// An offer still waiting has a reader parked on its channel; one that was
|
||||
// answered has already had it closed, and closing twice would panic.
|
||||
if entry.offer.Decision == DecisionPending {
|
||||
close(entry.decided)
|
||||
}
|
||||
|
||||
entry.offer.Decision = DecisionDeclined
|
||||
entry.offer.State = StateCancelled
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
57
client/internal/filedrop/progress.go
Normal file
57
client/internal/filedrop/progress.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// progressInterval is the floor between two progress reports of one item. A
|
||||
// percent of a large file can pass in a millisecond, and every report ends up
|
||||
// writing history and waking the UI, so the rate is capped regardless.
|
||||
const progressInterval = 200 * time.Millisecond
|
||||
|
||||
// progressReader reports how much of an item has moved as it is read. Reports
|
||||
// are thinned to one per whole percent, and to one per progressInterval when
|
||||
// the bytes outrun even that; the last one always goes through so a transfer
|
||||
// never stops a tick short of done.
|
||||
type progressReader struct {
|
||||
r io.Reader
|
||||
sent int64
|
||||
total int64
|
||||
report func(sent int64)
|
||||
percent int
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func (p *progressReader) Read(b []byte) (int, error) {
|
||||
n, err := p.r.Read(b)
|
||||
if n == 0 {
|
||||
return n, err
|
||||
}
|
||||
|
||||
p.sent += int64(n)
|
||||
if p.due(time.Now()) {
|
||||
p.report(p.sent)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// due decides whether the reader has moved enough, and waited long enough, to
|
||||
// be worth another report.
|
||||
func (p *progressReader) due(now time.Time) bool {
|
||||
if p.total <= 0 || p.sent >= p.total {
|
||||
return true
|
||||
}
|
||||
|
||||
percent := int(p.sent * 100 / p.total)
|
||||
if percent == p.percent {
|
||||
return false
|
||||
}
|
||||
if !p.last.IsZero() && now.Sub(p.last) < progressInterval {
|
||||
return false
|
||||
}
|
||||
|
||||
p.percent = percent
|
||||
p.last = now
|
||||
return true
|
||||
}
|
||||
108
client/internal/filedrop/progress_test.go
Normal file
108
client/internal/filedrop/progress_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProgressReader_ReportsEveryPercentAtMost(t *testing.T) {
|
||||
var reports []int64
|
||||
// Read one byte at a time out of a 1000 byte payload: without thinning that
|
||||
// would be 1000 reports for 100 percent worth of progress.
|
||||
reader := &progressReader{
|
||||
r: bytes.NewReader(make([]byte, 1000)),
|
||||
total: 1000,
|
||||
report: func(sent int64) { reports = append(reports, sent) },
|
||||
}
|
||||
|
||||
buf := make([]byte, 1)
|
||||
for {
|
||||
_, err := reader.Read(buf)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.LessOrEqual(t, len(reports), 100, "One report per percent at most")
|
||||
assert.Equal(t, int64(1000), reports[len(reports)-1], "The finished item must be reported")
|
||||
}
|
||||
|
||||
func TestProgressReader_HoldsBackFasterThanTheInterval(t *testing.T) {
|
||||
reader := &progressReader{total: 1000, report: func(int64) {}}
|
||||
start := time.Now()
|
||||
|
||||
reader.sent = 10
|
||||
require.True(t, reader.due(start), "The first percent goes through")
|
||||
|
||||
reader.sent = 20
|
||||
assert.False(t, reader.due(start.Add(progressInterval/2)),
|
||||
"A percent arriving inside the interval waits")
|
||||
|
||||
assert.True(t, reader.due(start.Add(progressInterval)),
|
||||
"The same percent goes through once the interval has passed")
|
||||
}
|
||||
|
||||
func TestProgressReader_ReportsTheLastByteWhateverTheInterval(t *testing.T) {
|
||||
reader := &progressReader{total: 1000, report: func(int64) {}}
|
||||
start := time.Now()
|
||||
|
||||
reader.sent = 500
|
||||
require.True(t, reader.due(start))
|
||||
|
||||
reader.sent = 1000
|
||||
assert.True(t, reader.due(start.Add(time.Millisecond)),
|
||||
"A finished item is never held back by the interval")
|
||||
}
|
||||
|
||||
func TestProgressReader_ReportsEveryReadWithoutASize(t *testing.T) {
|
||||
// A payload of unknown size has no percent to thin against, so every read
|
||||
// is worth reporting rather than none.
|
||||
reader := &progressReader{total: 0, report: func(int64) {}}
|
||||
|
||||
reader.sent = 1
|
||||
assert.True(t, reader.due(time.Now()))
|
||||
}
|
||||
|
||||
func TestAcceptedReader_StopsOnceTheOfferIsWithdrawn(t *testing.T) {
|
||||
accepted := true
|
||||
reader := &acceptedReader{
|
||||
r: bytes.NewReader(make([]byte, 1024)),
|
||||
accepted: func() bool { return accepted },
|
||||
// Backdated so the very first read checks rather than waiting out the
|
||||
// interval.
|
||||
last: time.Now().Add(-progressInterval),
|
||||
}
|
||||
|
||||
buf := make([]byte, 128)
|
||||
n, err := reader.Read(buf)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 128, n, "An accepted offer reads normally")
|
||||
|
||||
accepted = false
|
||||
reader.last = time.Now().Add(-progressInterval)
|
||||
|
||||
_, err = reader.Read(buf)
|
||||
assert.ErrorIs(t, err, ErrNotAccepted, "A withdrawn offer stops the copy")
|
||||
}
|
||||
|
||||
func TestAcceptedReader_ChecksNoMoreOftenThanTheInterval(t *testing.T) {
|
||||
checks := 0
|
||||
reader := &acceptedReader{
|
||||
r: bytes.NewReader(make([]byte, 4096)),
|
||||
accepted: func() bool { checks++; return true },
|
||||
}
|
||||
|
||||
buf := make([]byte, 1)
|
||||
for range 32 {
|
||||
_, err := reader.Read(buf)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.LessOrEqual(t, checks, 1, "The offer store is not locked once per read")
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
278
client/internal/filedrop/receiver.go
Normal file
278
client/internal/filedrop/receiver.go
Normal file
@@ -0,0 +1,278 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// The decision is read once, at the top, but a whole file goes into this
|
||||
// one request: a receiver that declines halfway through would otherwise be
|
||||
// streamed the rest of it, into a spool it has already thrown away.
|
||||
watched := &acceptedReader{
|
||||
r: body,
|
||||
accepted: func() bool {
|
||||
current, ok := r.offers.Get(sender.key, id)
|
||||
return ok && current.Decision == DecisionAccepted
|
||||
},
|
||||
}
|
||||
|
||||
// Write drains the whole body before returning, so without a reader in
|
||||
// between the only progress the receiver would ever report is the finished
|
||||
// file. Reports are thinned the same way the sender thins its own.
|
||||
staged := &progressReader{
|
||||
r: watched,
|
||||
sent: offset,
|
||||
total: size,
|
||||
report: func(sent int64) {
|
||||
r.offers.SetProgress(id, index, sent)
|
||||
r.notifyProgress(offer, index, sent)
|
||||
},
|
||||
}
|
||||
|
||||
received, err := r.spool.Write(id, index, offset, staged, size)
|
||||
|
||||
// A withdrawn offer is an answer, not a failure: the state it moved to is
|
||||
// the one the user chose, and the spool is already gone.
|
||||
if errors.Is(err, ErrNotAccepted) {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// acceptedReader stops a staged copy as soon as the offer behind it stops being
|
||||
// accepted. The check runs on the same cadence as progress rather than on every
|
||||
// read: it takes the offer store's lock, and a request that keeps going for one
|
||||
// more chunk after a decline costs nothing.
|
||||
type acceptedReader struct {
|
||||
r io.Reader
|
||||
accepted func() bool
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func (a *acceptedReader) Read(b []byte) (int, error) {
|
||||
now := time.Now()
|
||||
if now.Sub(a.last) >= progressInterval {
|
||||
a.last = now
|
||||
if !a.accepted() {
|
||||
return 0, ErrNotAccepted
|
||||
}
|
||||
}
|
||||
return a.r.Read(b)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -445,7 +445,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
|
||||
conn.dumpState.NewLocalProxy()
|
||||
wgProxy, err = conn.newProxy(iceConnInfo.RemoteConn)
|
||||
if err != nil {
|
||||
conn.Log.Errorf("failed to add relayed net.Conn to local proxy: %v", err)
|
||||
conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err)
|
||||
return
|
||||
}
|
||||
ep = wgProxy.EndpointAddr()
|
||||
@@ -883,8 +883,9 @@ func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) {
|
||||
}
|
||||
|
||||
wgProxy := conn.config.WgConfig.WgInterface.GetProxy()
|
||||
if err := wgProxy.AddRelayedConn(conn.ctx, udpAddr, remoteConn); err != nil {
|
||||
return nil, fmt.Errorf("add relayed conn to proxy: %w", err)
|
||||
if err := wgProxy.AddTurnConn(conn.ctx, udpAddr, remoteConn); err != nil {
|
||||
conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return wgProxy, 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,
|
||||
|
||||
@@ -255,8 +255,8 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent
|
||||
return
|
||||
}
|
||||
|
||||
w.log.Debugf("agent dial")
|
||||
remoteConn, err := w.agentDial(ctx, agent, remoteOfferAnswer)
|
||||
w.log.Debugf("turn agent dial")
|
||||
remoteConn, err := w.turnAgentDial(ctx, agent, remoteOfferAnswer)
|
||||
if err != nil {
|
||||
w.log.Debugf("failed to dial the remote peer: %s", err)
|
||||
w.closeAgent(agent, w.agentDialerCancel)
|
||||
@@ -517,8 +517,8 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia
|
||||
w.logSuccessfulPaths(agent)
|
||||
return
|
||||
case ice.ConnectionStateFailed, ice.ConnectionStateDisconnected, ice.ConnectionStateClosed:
|
||||
// ice.ConnectionStateClosed happens when we recreate the agent. The P2P to relay switch requires
|
||||
// notifying conn.onICEStateDisconnected so it can update the currently used priority.
|
||||
// ice.ConnectionStateClosed happens when we recreate the agent. For the P2P to TURN switch important to
|
||||
// notify the conn.onICEStateDisconnected changes to update the current used priority
|
||||
|
||||
sessionChanged := w.closeAgent(agent, dialerCancel)
|
||||
|
||||
@@ -532,7 +532,7 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WorkerICE) agentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) {
|
||||
func (w *WorkerICE) turnAgentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) {
|
||||
if isController(w.config) {
|
||||
return agent.Dial(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd)
|
||||
} else {
|
||||
|
||||
@@ -87,10 +87,9 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
|
||||
// RemoveProfileState deletes the per-profile state file (which holds the
|
||||
// account email used for the SSO login hint and the UI display). Called after
|
||||
// profile removal; logout keeps the file so the next login can pass the email
|
||||
// as the login_hint. The state file only stores the email, so deleting it is
|
||||
// equivalent to clearing it; the next SSO login recreates it. A missing file
|
||||
// is not an error.
|
||||
// a successful logout so a logged-out profile no longer shows a stale account
|
||||
// email. The state file only stores the email, so deleting it is equivalent to
|
||||
// clearing it; the next SSO login recreates it. A missing file is not an error.
|
||||
func (pm *ProfileManager) RemoveProfileState(profileName string) error {
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package systemops
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSortRouteCandidates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
candidates []candidateRoute
|
||||
wantOrder []uint32
|
||||
}{
|
||||
{
|
||||
name: "longest prefix wins over metrics",
|
||||
candidates: []candidateRoute{
|
||||
{interfaceIndex: 1, prefixLength: 0, routeMetric: 0, interfaceMetric: 5},
|
||||
{interfaceIndex: 2, prefixLength: 24, routeMetric: 100, interfaceMetric: 50},
|
||||
},
|
||||
wantOrder: []uint32{2, 1},
|
||||
},
|
||||
{
|
||||
// Windows ranks equal-length prefixes by route metric + interface metric,
|
||||
// so a higher route metric on a low metric interface can still win.
|
||||
name: "combined metric beats route metric alone",
|
||||
candidates: []candidateRoute{
|
||||
{interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100},
|
||||
{interfaceIndex: 5, prefixLength: 0, routeMetric: 10, interfaceMetric: 5},
|
||||
},
|
||||
wantOrder: []uint32{5, 8},
|
||||
},
|
||||
{
|
||||
name: "lower combined metric wins",
|
||||
candidates: []candidateRoute{
|
||||
{interfaceIndex: 5, prefixLength: 0, routeMetric: 300, interfaceMetric: 5},
|
||||
{interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100},
|
||||
},
|
||||
wantOrder: []uint32{8, 5},
|
||||
},
|
||||
{
|
||||
name: "equal combined metric falls back to route metric",
|
||||
candidates: []candidateRoute{
|
||||
{interfaceIndex: 1, prefixLength: 0, routeMetric: 20, interfaceMetric: 10},
|
||||
{interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 25},
|
||||
},
|
||||
wantOrder: []uint32{2, 1},
|
||||
},
|
||||
{
|
||||
// The metrics are uint32 on the Windows side, so the sum must not wrap.
|
||||
name: "combined metric beyond the uint32 range",
|
||||
candidates: []candidateRoute{
|
||||
{interfaceIndex: 1, prefixLength: 0, routeMetric: math.MaxUint32, interfaceMetric: 5},
|
||||
{interfaceIndex: 2, prefixLength: 0, routeMetric: math.MaxUint32 - 10, interfaceMetric: 5},
|
||||
},
|
||||
wantOrder: []uint32{2, 1},
|
||||
},
|
||||
{
|
||||
name: "unknown interface metric ranks on route metric only",
|
||||
candidates: []candidateRoute{
|
||||
{interfaceIndex: 1, prefixLength: 0, routeMetric: 30, interfaceMetric: -1},
|
||||
{interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 10},
|
||||
},
|
||||
wantOrder: []uint32{2, 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sortRouteCandidates(tt.candidates)
|
||||
|
||||
got := make([]uint32, 0, len(tt.candidates))
|
||||
for _, c := range tt.candidates {
|
||||
got = append(got, c.interfaceIndex)
|
||||
}
|
||||
assert.Equal(t, tt.wantOrder, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -882,40 +882,26 @@ func getInterfaceMetric(interfaceIndex uint32, family int16) int {
|
||||
return int(ipInterfaceRow.Metric)
|
||||
}
|
||||
|
||||
// sortRouteCandidates sorts route candidates by priority: prefix length -> combined metric -> route metric.
|
||||
// Windows prefers the longest matching prefix and, among prefixes of the same length, the lowest metric, see
|
||||
// https://learn.microsoft.com/en-us/windows-hardware/customize/desktop/unattend/microsoft-windows-tcpip-interfaces-interface-routes-route-metric
|
||||
// sortRouteCandidates sorts route candidates by priority: prefix length -> route metric -> interface metric
|
||||
func sortRouteCandidates(candidates []candidateRoute) {
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].prefixLength != candidates[j].prefixLength {
|
||||
return candidates[i].prefixLength > candidates[j].prefixLength
|
||||
}
|
||||
mi, mj := combinedMetric(candidates[i]), combinedMetric(candidates[j])
|
||||
if mi != mj {
|
||||
return mi < mj
|
||||
if candidates[i].routeMetric != candidates[j].routeMetric {
|
||||
return candidates[i].routeMetric < candidates[j].routeMetric
|
||||
}
|
||||
return candidates[i].routeMetric < candidates[j].routeMetric
|
||||
return candidates[i].interfaceMetric < candidates[j].interfaceMetric
|
||||
})
|
||||
}
|
||||
|
||||
// combinedMetric returns the effective metric Windows uses to rank routes with an equal prefix length:
|
||||
// the sum of the route metric and the metric of the interface the route is on, see
|
||||
// https://learn.microsoft.com/en-us/windows-server/networking/technologies/network-subsystem/net-sub-interface-metric
|
||||
// An unknown interface metric contributes nothing.
|
||||
func combinedMetric(candidate candidateRoute) uint64 {
|
||||
if candidate.interfaceMetric < 0 {
|
||||
return uint64(candidate.routeMetric)
|
||||
}
|
||||
return uint64(candidate.routeMetric) + uint64(candidate.interfaceMetric)
|
||||
}
|
||||
|
||||
// GetBestInterface finds the best interface for reaching a destination,
|
||||
// excluding the VPN interface to avoid routing loops.
|
||||
//
|
||||
// Route selection priority:
|
||||
// 1. Longest prefix match (most specific route)
|
||||
// 2. Lowest combined metric (route metric + interface metric)
|
||||
// 3. Lowest route metric.
|
||||
// 2. Lowest route metric
|
||||
// 3. Lowest interface metric
|
||||
func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) {
|
||||
var skipInterfaceIndex int
|
||||
if vpnIntf != "" {
|
||||
@@ -939,6 +925,7 @@ func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) {
|
||||
return nil, fmt.Errorf("no route to %s", dest)
|
||||
}
|
||||
|
||||
// Sort routes: prefix length -> route metric -> interface metric
|
||||
sortRouteCandidates(candidates)
|
||||
|
||||
for _, candidate := range candidates {
|
||||
|
||||
@@ -5,7 +5,6 @@ package systemops
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
@@ -30,7 +29,6 @@ func ensureIPv6DefaultRoute(t *testing.T) {
|
||||
}
|
||||
if err := netlink.RouteAdd(route); err != nil {
|
||||
if errors.Is(err, syscall.EEXIST) {
|
||||
requireUsableIPv6Nexthop(t)
|
||||
return
|
||||
}
|
||||
t.Skipf("install IPv6 fallback default route: %v", err)
|
||||
@@ -40,36 +38,4 @@ func ensureIPv6DefaultRoute(t *testing.T) {
|
||||
t.Logf("delete IPv6 fallback default route: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
requireUsableIPv6Nexthop(t)
|
||||
}
|
||||
|
||||
// requireUsableIPv6Nexthop skips the test unless the resolved IPv6 default
|
||||
// nexthop can actually carry a route. Installing the default route succeeding
|
||||
// does not imply the kernel accepts it as a nexthop for a concrete prefix.
|
||||
func requireUsableIPv6Nexthop(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
nexthop, err := GetNextHop(netip.IPv6Unspecified())
|
||||
if err != nil {
|
||||
t.Skipf("resolve IPv6 default nexthop: %v", err)
|
||||
}
|
||||
|
||||
probe := &netlink.Route{
|
||||
Scope: netlink.SCOPE_UNIVERSE,
|
||||
Table: syscall.RT_TABLE_MAIN,
|
||||
Family: netlink.FAMILY_V6,
|
||||
Dst: &net.IPNet{IP: net.ParseIP("100::64"), Mask: net.CIDRMask(128, 128)},
|
||||
}
|
||||
require.NoError(t, addNextHop(nexthop, probe), "build IPv6 probe route")
|
||||
|
||||
switch err := netlink.RouteAdd(probe); {
|
||||
case err == nil:
|
||||
if err := netlink.RouteDel(probe); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
t.Logf("delete IPv6 probe route: %v", err)
|
||||
}
|
||||
case errors.Is(err, syscall.EEXIST):
|
||||
default:
|
||||
t.Skipf("IPv6 nexthop %s unusable for route installation: %v", nexthop, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,32 +37,23 @@
|
||||
// Updater Process (Setup):
|
||||
//
|
||||
// 1. Receives parameters from service via command-line arguments
|
||||
// 2. Terminates the UI so the installer does not have to replace a locked image
|
||||
// file, which would otherwise leave the install needing a reboot
|
||||
// 3. Runs installer with appropriate silent/quiet flags:
|
||||
// 2. Runs installer with appropriate silent/quiet flags:
|
||||
// - Windows EXE: installer.exe /S
|
||||
// - Windows MSI: msiexec.exe /i installer.msi /qn /norestart REBOOT=ReallySuppress /l*v msi.log
|
||||
// - Windows MSI: msiexec.exe /i installer.msi /quiet /qn /l*v msi.log
|
||||
// - macOS PKG: installer -pkg installer.pkg -target /
|
||||
// - macOS Homebrew: brew upgrade netbirdio/tap/netbird
|
||||
// 4. Installer terminates the daemon
|
||||
// 5. Installer replaces binaries with new version
|
||||
// 6. Updater waits for installer to complete. On Windows, MSI exit codes 3010
|
||||
// (ERROR_SUCCESS_REBOOT_REQUIRED) and 1641 (ERROR_SUCCESS_REBOOT_INITIATED)
|
||||
// are a pending-reboot outcome, not a failure: the install succeeded, but
|
||||
// some files are only replaced on the next restart (the reboot itself is
|
||||
// suppressed via /norestart and REBOOT=ReallySuppress), and the flow
|
||||
// continues as on success
|
||||
// 7. Updater restarts daemon:
|
||||
// 3. Installer terminates daemon and UI processes
|
||||
// 4. Installer replaces binaries with new version
|
||||
// 5. Updater waits for installer to complete
|
||||
// 6. Updater restarts daemon:
|
||||
// - Windows: netbird.exe service start
|
||||
// - macOS/Linux: netbird service start
|
||||
// 8. Updater restarts UI:
|
||||
// - Windows: Launches netbird-ui.exe using CreateProcessAsUser in every
|
||||
// session it was terminated in, falling back to the active console session
|
||||
// 7. Updater restarts UI:
|
||||
// - Windows: Launches netbird-ui.exe as active console user using CreateProcessAsUser
|
||||
// - macOS: Uses launchctl asuser to launch NetBird.app for console user
|
||||
// - Linux: Not implemented (UI typically auto-starts)
|
||||
// 9. Updater writes result.json with success/error status (a pending reboot is
|
||||
// recorded as success)
|
||||
// 10. Updater process exits
|
||||
// 8. Updater writes result.json with success/error status
|
||||
// 9. Updater process exits
|
||||
//
|
||||
// # Result Communication
|
||||
//
|
||||
|
||||
@@ -42,9 +42,6 @@ func NewWithDir(tempDir string) *Installer {
|
||||
// This will run by the original service process
|
||||
func (u *Installer) RunInstallation(ctx context.Context, targetVersion string) (err error) {
|
||||
resultHandler := NewResultHandler(u.tempDir)
|
||||
if err := resultHandler.ClearStaleResult(); err != nil {
|
||||
log.Warnf("clear stale installer result: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
|
||||
@@ -2,7 +2,6 @@ package installer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -23,12 +22,6 @@ const (
|
||||
|
||||
msiLogFile = "msi.log"
|
||||
|
||||
// ERROR_SUCCESS_REBOOT_REQUIRED and ERROR_SUCCESS_REBOOT_INITIATED
|
||||
msiRebootRequired = 3010
|
||||
msiRebootInitiated = 1641
|
||||
|
||||
processExitWait = 10 * time.Second
|
||||
|
||||
msiDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.msi"
|
||||
exeDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.exe"
|
||||
)
|
||||
@@ -45,8 +38,6 @@ var (
|
||||
func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string, daemonFolder string) (resultErr error) {
|
||||
resultHandler := NewResultHandler(u.tempDir)
|
||||
|
||||
var uiSessions []uint32
|
||||
|
||||
// Always ensure daemon and UI are restarted after setup
|
||||
defer func() {
|
||||
log.Infof("starting daemon back")
|
||||
@@ -55,7 +46,7 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string
|
||||
}
|
||||
|
||||
log.Infof("starting UI back")
|
||||
if err := u.startUI(daemonFolder, uiSessions); err != nil {
|
||||
if err := u.startUIAsUser(daemonFolder); err != nil {
|
||||
log.Errorf("failed to start UI: %v", err)
|
||||
}
|
||||
|
||||
@@ -84,14 +75,6 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string
|
||||
return
|
||||
}
|
||||
|
||||
// The UI holds an open handle on its own image. Left running, Restart Manager
|
||||
// cannot shut it down (msiexec runs as LocalSystem here, the UI as the
|
||||
// interactive user), so the MSI falls back to replacing the file on reboot and
|
||||
// marks the install as restart-required. The deferred close-application action
|
||||
// in the package runs too late to prevent that, it happens after
|
||||
// InstallValidate has already registered the file as in use.
|
||||
uiSessions = killUI()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch installerType {
|
||||
case TypeExe:
|
||||
@@ -101,9 +84,7 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string
|
||||
installerDir := filepath.Dir(installerFile)
|
||||
logPath := filepath.Join(installerDir, msiLogFile)
|
||||
log.Infof("run msi installer: %s", installerFile)
|
||||
// REBOOT=ReallySuppress: a silent install has no way to ask, so without it
|
||||
// msiexec reboots the machine on its own if it decides one is needed.
|
||||
cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/qn", "/norestart", "REBOOT=ReallySuppress", "/l*v", logPath)
|
||||
cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/quiet", "/qn", "/l*v", logPath)
|
||||
}
|
||||
|
||||
cmd.Dir = filepath.Dir(installerFile)
|
||||
@@ -114,13 +95,9 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string
|
||||
}
|
||||
|
||||
log.Infof("installer started with PID %d", cmd.Process.Pid)
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if !isRebootPending(err) {
|
||||
resultErr = err
|
||||
log.Errorf("installer process finished with error: %v", err)
|
||||
return
|
||||
}
|
||||
log.Warnf("installer completed but reported a pending reboot, some files will be replaced on the next restart")
|
||||
if resultErr = cmd.Wait(); resultErr != nil {
|
||||
log.Errorf("installer process finished with error: %v", resultErr)
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -140,142 +117,16 @@ func (u *Installer) startDaemon(daemonFolder string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Installer) startUI(daemonFolder string, sessionIDs []uint32) error {
|
||||
func (u *Installer) startUIAsUser(daemonFolder string) error {
|
||||
uiPath := filepath.Join(daemonFolder, uiName)
|
||||
log.Infof("starting netbird-ui: %s", uiPath)
|
||||
|
||||
if len(sessionIDs) == 0 {
|
||||
sessionID := windows.WTSGetActiveConsoleSessionId()
|
||||
if sessionID == 0xFFFFFFFF {
|
||||
return fmt.Errorf("no active user session found")
|
||||
}
|
||||
sessionIDs = []uint32{sessionID}
|
||||
// Get the active console session ID
|
||||
sessionID := windows.WTSGetActiveConsoleSessionId()
|
||||
if sessionID == 0xFFFFFFFF {
|
||||
return fmt.Errorf("no active user session found")
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, sessionID := range sessionIDs {
|
||||
if err := startUIInSession(uiPath, sessionID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("session %d: %w", sessionID, err))
|
||||
continue
|
||||
}
|
||||
log.Infof("netbird-ui started successfully in session %d", sessionID)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// isRebootPending reports whether the installer exit code means it succeeded but
|
||||
// left work for the next restart. The reboot itself is suppressed, so this is not
|
||||
// a failure.
|
||||
func isRebootPending(err error) bool {
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch exitErr.ExitCode() {
|
||||
case msiRebootRequired, msiRebootInitiated:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// killUI terminates any running netbird-ui process and returns the IDs of the
|
||||
// interactive sessions the terminated processes belonged to. Setup starts the
|
||||
// UI again in those sessions once the installer is done.
|
||||
func killUI() []uint32 {
|
||||
pids, err := processIDsByName(uiName)
|
||||
if err != nil {
|
||||
log.Warnf("failed to look up %s processes: %v", uiName, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
sessions := make(map[uint32]struct{})
|
||||
for _, pid := range pids {
|
||||
var sessionID uint32
|
||||
if err := windows.ProcessIdToSessionId(pid, &sessionID); err != nil {
|
||||
log.Warnf("failed to look up session of %s (PID %d): %v", uiName, pid, err)
|
||||
}
|
||||
|
||||
if err := terminateProcess(pid); err != nil {
|
||||
log.Warnf("failed to terminate %s (PID %d): %v", uiName, pid, err)
|
||||
continue
|
||||
}
|
||||
log.Infof("terminated %s (PID %d) in session %d", uiName, pid, sessionID)
|
||||
|
||||
if sessionID != 0 {
|
||||
sessions[sessionID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
sessionIDs := make([]uint32, 0, len(sessions))
|
||||
for sessionID := range sessions {
|
||||
sessionIDs = append(sessionIDs, sessionID)
|
||||
}
|
||||
return sessionIDs
|
||||
}
|
||||
|
||||
func processIDsByName(name string) ([]uint32, error) {
|
||||
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create process snapshot: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := windows.CloseHandle(snapshot); err != nil {
|
||||
log.Warnf("failed to close process snapshot: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var entry windows.ProcessEntry32
|
||||
entry.Size = uint32(unsafe.Sizeof(entry))
|
||||
|
||||
var pids []uint32
|
||||
for err = windows.Process32First(snapshot, &entry); err == nil; err = windows.Process32Next(snapshot, &entry) {
|
||||
if strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), name) {
|
||||
pids = append(pids, entry.ProcessID)
|
||||
}
|
||||
}
|
||||
if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
||||
return nil, fmt.Errorf("enumerate processes: %w", err)
|
||||
}
|
||||
|
||||
return pids, nil
|
||||
}
|
||||
|
||||
func terminateProcess(pid uint32) error {
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid)
|
||||
if err != nil {
|
||||
// The process may have exited between enumeration and now.
|
||||
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("open process: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := windows.CloseHandle(handle); err != nil {
|
||||
log.Warnf("failed to close process handle: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := windows.TerminateProcess(handle, 0); err != nil {
|
||||
return fmt.Errorf("terminate process: %w", err)
|
||||
}
|
||||
|
||||
// Wait for the handle to signal so the image file is released before the
|
||||
// installer tries to overwrite it. A timeout is reported through the returned
|
||||
// event, not through err, which stays nil unless the wait itself failed.
|
||||
event, err := windows.WaitForSingleObject(handle, uint32(processExitWait.Milliseconds()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for process exit: %w", err)
|
||||
}
|
||||
if event != windows.WAIT_OBJECT_0 {
|
||||
return fmt.Errorf("wait for process exit: unexpected wait result %#x", event)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func startUIInSession(uiPath string, sessionID uint32) error {
|
||||
// Get the user token for that session
|
||||
var userToken windows.Token
|
||||
err := windows.WTSQueryUserToken(sessionID, &userToken)
|
||||
@@ -307,16 +158,6 @@ func startUIInSession(uiPath string, sessionID uint32) error {
|
||||
}
|
||||
}()
|
||||
|
||||
var env *uint16
|
||||
if err := windows.CreateEnvironmentBlock(&env, primaryToken, false); err != nil {
|
||||
return fmt.Errorf("create environment block: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := windows.DestroyEnvironmentBlock(env); err != nil {
|
||||
log.Warnf("failed to destroy environment block: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Prepare startup info
|
||||
var si windows.StartupInfo
|
||||
si.Cb = uint32(unsafe.Sizeof(si))
|
||||
@@ -339,7 +180,7 @@ func startUIInSession(uiPath string, sessionID uint32) error {
|
||||
nil,
|
||||
false,
|
||||
creationFlags,
|
||||
env,
|
||||
nil,
|
||||
nil,
|
||||
&si,
|
||||
&pi,
|
||||
@@ -356,6 +197,7 @@ func startUIInSession(uiPath string, sessionID uint32) error {
|
||||
log.Warnf("failed to close thread handle: %v", err)
|
||||
}
|
||||
|
||||
log.Infof("netbird-ui started successfully in session %d", sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// exitErrorWithCode returns a real *exec.ExitError carrying the given exit code.
|
||||
func exitErrorWithCode(t *testing.T, code int) error {
|
||||
t.Helper()
|
||||
|
||||
err := exec.Command("cmd.exe", "/c", "exit "+strconv.Itoa(code)).Run()
|
||||
if err == nil {
|
||||
t.Fatalf("expected a non-zero exit for code %d", code)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestIsRebootPending(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
want bool
|
||||
}{
|
||||
{name: "reboot required", code: msiRebootRequired, want: true},
|
||||
{name: "reboot initiated", code: msiRebootInitiated, want: true},
|
||||
{name: "generic failure", code: 1603, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isRebootPending(exitErrorWithCode(t, tt.code)); got != tt.want {
|
||||
t.Errorf("isRebootPending(exit %d) = %v, want %v", tt.code, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessIDsByNameAndTerminate spawns a long-running system process, finds it
|
||||
// by name and terminates it, covering the path the updater uses to release the UI
|
||||
// image file before the installer replaces it.
|
||||
func TestProcessIDsByNameAndTerminate(t *testing.T) {
|
||||
cmd := exec.Command("ping.exe", "-n", "60", "127.0.0.1")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start ping: %v", err)
|
||||
}
|
||||
|
||||
pid := uint32(cmd.Process.Pid)
|
||||
killed := false
|
||||
t.Cleanup(func() {
|
||||
if !killed {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
// Name matching must be case-insensitive: the snapshot reports PING.EXE.
|
||||
pids, err := processIDsByName("ping.exe")
|
||||
if err != nil {
|
||||
t.Fatalf("processIDsByName: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Contains(pids, pid) {
|
||||
t.Fatalf("PID %d not among the ping.exe processes found: %v", pid, pids)
|
||||
}
|
||||
|
||||
if err := terminateProcess(pid); err != nil {
|
||||
t.Fatalf("terminateProcess: %v", err)
|
||||
}
|
||||
killed = true
|
||||
|
||||
// terminateProcess only returns once the handle has signalled, so the process
|
||||
// is already gone and Wait must not block. It exits with the code passed to
|
||||
// TerminateProcess, which is 0, so Wait reports no error.
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("wait for terminated ping: %v", err)
|
||||
}
|
||||
if !cmd.ProcessState.Exited() {
|
||||
t.Error("process did not exit after terminateProcess")
|
||||
}
|
||||
|
||||
remaining, err := processIDsByName("ping.exe")
|
||||
if err != nil {
|
||||
t.Fatalf("processIDsByName after terminate: %v", err)
|
||||
}
|
||||
if slices.Contains(remaining, pid) {
|
||||
t.Errorf("PID %d still listed after terminateProcess", pid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIDsByNameNoMatch(t *testing.T) {
|
||||
pids, err := processIDsByName("netbird-nonexistent-process.exe")
|
||||
if err != nil {
|
||||
t.Fatalf("processIDsByName: %v", err)
|
||||
}
|
||||
if len(pids) != 0 {
|
||||
t.Errorf("expected no matches, got %v", pids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRebootPendingNonExitError(t *testing.T) {
|
||||
if isRebootPending(errors.New("start installer: file not found")) {
|
||||
t.Error("a non-exit error must not be treated as a pending reboot")
|
||||
}
|
||||
}
|
||||
@@ -54,12 +54,6 @@ func (rh *ResultHandler) GetErrorResultReason() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ClearStaleResult removes a result file left over from a previous installation
|
||||
// attempt so result watchers cannot read an outdated outcome for the current attempt.
|
||||
func (rh *ResultHandler) ClearStaleResult() error {
|
||||
return rh.cleanup()
|
||||
}
|
||||
|
||||
func (rh *ResultHandler) WriteSuccess() error {
|
||||
result := Result{
|
||||
Success: true,
|
||||
|
||||
@@ -28,9 +28,7 @@ type State struct {
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
// New creates a State that starts online. Platforms without network tracking
|
||||
// pass a nil *State instead: the read methods treat nil as always online and
|
||||
// never block, so consumers need no nil guards.
|
||||
// New creates a State that starts online.
|
||||
func New() *State {
|
||||
return &State{
|
||||
online: true,
|
||||
@@ -39,8 +37,7 @@ func New() *State {
|
||||
}
|
||||
|
||||
// Set records whether the OS reports any usable network. Transitions wake up
|
||||
// all Wait callers immediately. Unlike the read methods, Set is not nil-safe:
|
||||
// it is only for the platform owner that created the State with New.
|
||||
// all Wait callers immediately.
|
||||
func (s *State) Set(online bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
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()
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel"
|
||||
|
||||
|
||||
@@ -30,11 +30,6 @@ func GetInfo(ctx context.Context) *Info {
|
||||
kernelVersion = osInfo[2]
|
||||
}
|
||||
|
||||
addrs, err := networkAddresses()
|
||||
if err != nil {
|
||||
log.Warnf("discover network addresses: %s", err)
|
||||
}
|
||||
|
||||
gio := &Info{
|
||||
GoOS: runtime.GOOS,
|
||||
Kernel: kernel,
|
||||
@@ -46,7 +41,6 @@ func GetInfo(ctx context.Context) *Info {
|
||||
NetbirdVersion: version.NetbirdVersion(),
|
||||
UIVersion: extractUIVersion(ctx),
|
||||
KernelVersion: kernelVersion,
|
||||
NetworkAddresses: addrs,
|
||||
SystemSerialNumber: serial(),
|
||||
SystemProductName: productModel(),
|
||||
SystemManufacturer: productManufacturer(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !ios && !android
|
||||
//go:build !ios
|
||||
|
||||
package system
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var iFaceDiscover IFaceDiscover
|
||||
|
||||
type IFaceDiscover interface {
|
||||
IFaces() (string, error)
|
||||
}
|
||||
|
||||
// SetIFaceDiscover configures the Android interface discovery provider.
|
||||
func SetIFaceDiscover(discover IFaceDiscover) {
|
||||
iFaceDiscover = discover
|
||||
}
|
||||
|
||||
func networkAddresses() ([]NetworkAddress, error) {
|
||||
if iFaceDiscover == nil {
|
||||
return nil, nil
|
||||
}
|
||||
ifaces, err := iFaceDiscover.IFaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var netAddresses []NetworkAddress
|
||||
for _, line := range strings.Split(ifaces, "\n") {
|
||||
addresses, ok := interfaceAddresses(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, address := range addresses {
|
||||
netAddr, ok := toNetworkAddress(address)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isDuplicated(netAddresses, netAddr) {
|
||||
continue
|
||||
}
|
||||
netAddresses = append(netAddresses, netAddr)
|
||||
}
|
||||
}
|
||||
return netAddresses, nil
|
||||
}
|
||||
|
||||
func interfaceAddresses(line string) ([]string, bool) {
|
||||
parts := strings.Split(line, "|")
|
||||
if len(parts) != 2 {
|
||||
return nil, false
|
||||
}
|
||||
flags := strings.Fields(parts[0])
|
||||
if len(flags) != 8 {
|
||||
return nil, false
|
||||
}
|
||||
up, loopback := flags[3], flags[5]
|
||||
if up != "true" || loopback == "true" {
|
||||
return nil, false
|
||||
}
|
||||
return strings.Fields(parts[1]), true
|
||||
}
|
||||
|
||||
func toNetworkAddress(address string) (NetworkAddress, bool) {
|
||||
prefix, err := netip.ParsePrefix(address)
|
||||
if err != nil {
|
||||
return NetworkAddress{}, false
|
||||
}
|
||||
if prefix.Addr().Is4In6() {
|
||||
if prefix.Bits() < 96 {
|
||||
return NetworkAddress{}, false
|
||||
}
|
||||
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
|
||||
}
|
||||
ip := prefix.Addr()
|
||||
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsMulticast() {
|
||||
return NetworkAddress{}, false
|
||||
}
|
||||
return NetworkAddress{NetIP: prefix}, true
|
||||
}
|
||||
|
||||
func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool {
|
||||
for _, duplicated := range addresses {
|
||||
if duplicated.NetIP == addr.NetIP {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !ios && !android
|
||||
//go:build !ios
|
||||
|
||||
package system
|
||||
|
||||
|
||||
@@ -6,11 +6,9 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
@@ -62,19 +60,9 @@ func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (Exten
|
||||
|
||||
// a request from the UI implies a graphical session, which the daemon cannot detect itself
|
||||
req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: true}
|
||||
hint := p.Hint
|
||||
if hint == "" {
|
||||
pm := profilemanager.NewProfileManager()
|
||||
if active, perr := pm.GetActiveProfile(); perr != nil {
|
||||
log.Debugf("failed to get active profile for login hint: %v", perr)
|
||||
} else if state, serr := pm.GetProfileState(active.ID); serr != nil {
|
||||
log.Debugf("failed to get profile state for login hint: %v", serr)
|
||||
} else {
|
||||
hint = state.Email
|
||||
}
|
||||
}
|
||||
if hint != "" {
|
||||
req.Hint = &hint
|
||||
if p.Hint != "" {
|
||||
h := p.Hint
|
||||
req.Hint = &h
|
||||
}
|
||||
|
||||
resp, err := cli.RequestExtendAuthSession(ctx, req)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -764,19 +764,7 @@
|
||||
"message": "Sensible Informationen anonymisieren"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Verbirgt IP-Adressen, Domains und andere sensible Werte."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "Der Standardmodus lässt interne IPv4-Adressen und Peer-Namen für den Support lesbar. Der strikte Modus anonymisiert zusätzlich private (RFC 1918), CGNAT- und Link-Local-IP-Adressen, Peer-Namen und öffentliche WireGuard-Schlüssel. Wiederkehrende Werte erhalten denselben Platzhalter, sodass Peers unterscheidbar bleiben. Verwenden Sie den strikten Modus, wenn Sie das Debug-Paket außerhalb Ihrer Organisation weitergeben."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "Keine"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "Standard"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Strikt"
|
||||
"message": "Versteckt öffentliche IP-Adressen und nicht-NetBird-Domains in Logs."
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Systeminformationen einschließen"
|
||||
@@ -1351,13 +1339,173 @@
|
||||
"error.unknown": {
|
||||
"message": "Vorgang fehlgeschlagen."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Erfordert {actor}. Führen Sie stattdessen dies aus:"
|
||||
"nav.files.title": {
|
||||
"message": "Dateien"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Sie können dies deaktivieren, aber zum erneuten Aktivieren sind {actor} erforderlich:"
|
||||
"files.search.placeholder": {
|
||||
"message": "Übertragungen nach Datei oder Peer suchen"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Sie können dies aktivieren, aber zum erneuten Deaktivieren sind {actor} erforderlich:"
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,19 +764,7 @@
|
||||
"message": "Anonimizar información sensible"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Oculta direcciones IP, dominios y otros valores sensibles."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "El modo predeterminado mantiene legibles las direcciones IPv4 internas y los nombres de los peers para el soporte. El modo estricto anonimiza además las direcciones IP privadas (RFC 1918), CGNAT y de enlace local, los nombres de los peers y las claves públicas de WireGuard. Los valores recurrentes se asignan al mismo marcador de posición, por lo que los peers siguen siendo distinguibles. Use el modo estricto cuando comparta el paquete de diagnóstico fuera de su organización."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "Ninguno"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "Predeterminado"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Estricto"
|
||||
"message": "Oculta las direcciones IP públicas y los dominios ajenos a NetBird de los registros."
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Incluir información del sistema"
|
||||
@@ -1351,13 +1339,173 @@
|
||||
"error.unknown": {
|
||||
"message": "La operación falló."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Requiere {actor}. Ejecute esto en su lugar:"
|
||||
"nav.files.title": {
|
||||
"message": "Archivos"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Puede desactivarlo, pero volver a activarlo requiere {actor}:"
|
||||
"files.search.placeholder": {
|
||||
"message": "Buscar transferencias por archivo o peer"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Puede activarlo, pero volver a desactivarlo requiere {actor}:"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,19 +764,7 @@
|
||||
"message": "Anonymiser les informations sensibles"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Masque les adresses IP, les domaines et d'autres valeurs sensibles."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "Le mode par défaut garde les adresses IPv4 internes et les noms des pairs lisibles pour le support. Le mode strict anonymise en plus les adresses IP privées (RFC 1918), CGNAT et de lien local, les noms des pairs et les clés publiques WireGuard. Les valeurs récurrentes reçoivent le même espace réservé, les pairs restent donc distinguables. Utilisez le mode strict lorsque vous partagez le lot de diagnostic en dehors de votre organisation."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "Aucune"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "Par défaut"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Strict"
|
||||
"message": "Masque les adresses IP publiques et les domaines non-NetBird dans les journaux."
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Inclure les informations système"
|
||||
@@ -1351,13 +1339,173 @@
|
||||
"error.unknown": {
|
||||
"message": "L’opération a échoué."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Nécessite {actor}. Exécutez plutôt ceci :"
|
||||
"nav.files.title": {
|
||||
"message": "Fichiers"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor} :"
|
||||
"files.search.placeholder": {
|
||||
"message": "Rechercher des transferts par fichier ou pair"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Vous pouvez l’activer, mais le désactiver de nouveau nécessite {actor} :"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,19 +764,7 @@
|
||||
"message": "Érzékeny információk anonimizálása"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Elrejti az IP-címeket, a tartományokat és más érzékeny értékeket."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "Az Alapértelmezett szint a belső IPv4-címeket és a peer-neveket olvashatóan hagyja a támogatás számára. A Szigorú ezen felül anonimizálja a privát (RFC 1918), CGNAT és link-local IP-címeket, a peer-neveket és a WireGuard nyilvános kulcsokat. Az ismétlődő értékek ugyanazt a helyettesítőt kapják, így a peerek megkülönböztethetők maradnak. Használja a Szigorú szintet, ha a hibakeresési csomagot a szervezetén kívül osztja meg."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "Nincs"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "Alapértelmezett"
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Szigorú"
|
||||
"message": "Elrejti a nyilvános IP-címeket és a nem-NetBird tartományokat a naplókban."
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Rendszerinformációk beillesztése"
|
||||
@@ -1351,13 +1339,173 @@
|
||||
"error.unknown": {
|
||||
"message": "A művelet meghiúsult."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "{actor} szükséges hozzá. Futtassa inkább ezt:"
|
||||
"nav.files.title": {
|
||||
"message": "Fájlok"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges:"
|
||||
"files.search.placeholder": {
|
||||
"message": "Keresés fájl vagy peer szerint"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges:"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user