Commit Graph
10 Commits
Author SHA1 Message Date
Zoltán Papp ba9dae4a48 [client] Keep a withdrawal from discarding a payload being delivered
Completing an offer and delivering it ran with no lock held across the two,
while a sender could send DELETE at any point and withdraw ran spool.Remove
without looking at the offer's state. A withdrawal landing in that window
deleted the staged bytes out from under the copy: delivery failed with
"open spooled file: no such file or directory", the transfer was recorded as
failed, and the payload was gone although the sender had seen its upload
succeed.

A completed offer is no longer withdrawable, and publishing or discarding one
offer's payloads now serialises on a per-offer lock the two sinks share, so a
removal waits for a delivery in flight instead of racing it. deliver() drops
the spool as its last step and reaches it through removeLocked, since the
lock it would otherwise retake is the one it already holds.
2026-09-08 21:01:44 +02:00
Zoltán Papp ce08e529d3 [client] Bound what one peer can announce to the file drop receiver
validateOffer bounded the file count and inline text but only checked the
sign of an announced size, and nothing limited the aggregate or how many
offers a sender could keep open. Every announced byte is staged in the spool
before delivery, so a peer decided how much of the receiver's disk to take:
512 files of 8 GiB were accepted unchallenged, and a flood of offers each
raised its own consent prompt.

Sizes are now capped per file and per offer, and a sender is held to a fixed
number of open offers. The aggregate accumulates against the remaining
headroom instead of summing first, because 512 files of 2^60 wrap an int64
back through zero and a plain sum would report such an offer as nil bytes.

The count is of open offers, not lifetime ones, so settling one frees a slot.
2026-09-08 20:54:11 +02:00
Zoltán Papp ff85ea0838 [client] Enforce a file drop block on a transfer already in flight
The receiving policy was read once, in submitOffer, and never again. The
upload path and the reader guarding it both watched only the offer's own
Decision, so blocking a sender refused its next offer while the transfer it
had already started kept streaming into the spool and was delivered.

SetSenderRule tried to settle the sender's offers but went through Decide,
which only moves an offer out of Pending, and skipped everything else -
precisely the accepted ones that were still running. It now revokes any
non-terminal offer instead, and the staged copy re-reads the policy on the
same cadence it already checks the decision, so a block stops the bytes.

State.terminal carries the state list that Transfer.terminal duplicated.
2026-09-08 20:48:28 +02:00
Zoltan Papp ec0659cbf0 [client] Bind the file drop receiver only while receiving is enabled
The receiver was bound on every engine start regardless of the profile's
receiving mode, and a failed bind also left the manager without a tunnel
dialer, which took sending down with it.

Set the tunnel dialer before the bind so sending no longer depends on it,
and gate the bind on the receiving mode. A mode change now notifies the
engine, which binds or unbinds accordingly: turning receiving off releases
the listeners and the offer store, empties the spool and settles unfinished
incoming transfers as cancelled.

Also log file drop send and receive failures with the peer and the step that
broke, so a failure can be read from the client log alone.
2026-09-05 19:58:08 +02:00
Zoltan Papp 50197c7355 [client] Fix file drop delivery and refine the files UI
Seed the delivery directory from the IPC caller when the active profile
carries no username, so the default profile gets a destination instead of
failing every incoming transfer. Deliver text-only offers without requiring
a destination directory at all.

In the UI, drop the status label from the send picker rows, keep the dot,
and give the transfer list per-kind icons: a quote for text, a file with an
up or down arrow for files.
2026-09-05 19:12:43 +02:00
Zoltán Papp fa47b32b92 [client] Scope the iOS file drop listener to the tunnel interface
The receiver's host listener is a socket of the Network Extension, so the
SYN-ACK of an accepted connection followed the extension's own-traffic
bypass onto the physical interface and the sender never got an answer.
Bind the listeners to the tunnel interface index the same way the dial
already is; accepted sockets inherit the scope. Other platforms pass a
nil control and keep the current behavior.
2026-08-27 17:58:25 +02:00
Zoltán Papp 41906d4a1a [client] Pin the file drop port over the tunnel
The file drop server listened on 41421, which falls inside the ephemeral
port range on Linux (32768-60999) and Windows (49152-65535), so any
outbound connection could take it after boot. The receiver then bound a
dynamic port and advertised it over signaling, and the sender waited for
that advertisement before retrying.

That coupled a data plane feature to signaling traffic: once a peer
connection is established there is no reason for another offer or answer
to go out, so a sender could wait out the grace period for an
advertisement that never came.

Move the port to 22042, next to the SSH (22022) and DNS forwarder (22054)
ports and clear of both ephemeral ranges, and keep the tunnel side fixed
the way SSH does. A receiver that cannot bind it falls back to a dynamic
port and redirects 22042 to it with an inbound DNAT rule, so senders
always dial the well known port and never negotiate. NB_FILEDROP_PORT
overrides the local bind only.

The DNAT runs ahead of the filter on every backend (nftables prerouting
at NAT dest priority, iptables nat/PREROUTING, and the userspace filter's
translate-then-redecode path), so the netstack service registry keeps
taking the bound port.

This drops the port registry, the retry that waited on it, and the
signaling plumbing that fed it.
2026-08-25 18:51:21 +02:00
Zoltán Papp 861e0d5609 [client] Stage incoming file drop payloads through a platform sink
Android cannot address the user's shared storage by path, so a received
file had to land in app-private storage and be copied out afterwards,
needing twice the space of the transfer.

Put the staging area behind a Sink interface the receiver writes every
payload through. The filesystem spool implements it unchanged and stays
the default; a platform that cannot be addressed by path implements the
gomobile-bound half instead and stages payloads wherever it can reach.

The writer reports its own total rather than returning a written count:
gomobile copies a []byte argument into a fresh Java array and carries no
count back out.

A failed delivery now drops the staged payloads. The filesystem spool
swept them up on its next pass, but a sink holding entries the engine
cannot address by path has no such fallback.
2026-08-25 18:45:38 +02:00
Zoltan Papp b165e0126e [client] Report file drop progress, and stop a withdrawn transfer
The receiver reported progress once per file, after the whole body had
been staged: spool.Write drains its reader before returning, so a large
file sat at nothing until it jumped to done. It now stages through a
reader that reports as the bytes land, the same way the sender already
did, and both sides thin their reports to one per percent and one per
200ms so a fast transfer cannot flood the history or the UI.

Progress also becomes an event of its own. It was left out because a
report per byte would have been unusable; thinned, it costs a handful of
events a second and spares every UI a poll. The daemon's event bridge
ignores the new kind, so nothing is published where a notification would
be noise.

Withdrawing consent mid-transfer did nothing. Cancel routed through
Decide, which only moves an offer out of Pending, so an accepted offer
kept its decision, kept its spool, and kept being uploaded into: the
receiver's list went quiet while the sender ran to completion. Revoke
takes an offer back whatever it has already answered, the staging reader
gives up as soon as consent is gone, and the sender stops rather than
retrying a refusal three times over. A transfer withdrawn this way reads
as declined on both ends, which is what it is, rather than as a failure.
2026-08-18 14:34:44 +02:00
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