Files
netbird/client/internal/filedrop/history.go
Zoltán Papp 73cffdb702 Add peer-to-peer file drop
Files move directly between peers over the overlay, with no server in the
path. The receiver listens on the WireGuard address only, so the port is
unreachable from outside the tunnel, and every offer is matched to a known
peer before anything is read.

Consent is the default: an offer carries metadata alone, and no payload
moves until the receiver accepts. Policy is per profile and device-local —
off, ask, or auto-accept, with per-sender exceptions on top.

Policy and history live in the profile's preferences, so removing a profile
takes its file drop state with it. Transfers interrupted by a restart are
settled on load; nothing survives to finish them, and left alone they would
sit in the log as permanently pending.

The Android bindings pull payload bytes through a chunk-returning stream:
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.
2026-08-16 22:04:38 +02:00

206 lines
5.0 KiB
Go

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