Compare commits
12 Commits
0b06500b9a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b77c3e102 | ||
|
|
932a4bd7bb | ||
|
|
0ffe9fc29d | ||
|
|
06e7ce83e9 | ||
|
|
c8205c703b | ||
|
|
bd7879ce7b | ||
|
|
6a3ad787d5 | ||
|
|
153426367d | ||
|
|
3a105e2c9a | ||
|
|
a4bee36f45 | ||
|
|
0fe01e146c | ||
|
|
488431eaec |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ cli/flit
|
|||||||
|
|
||||||
# Host-specific deploy scripts (contain server addresses / SSH targets)
|
# Host-specific deploy scripts (contain server addresses / SSH targets)
|
||||||
deploy-pwa.sh
|
deploy-pwa.sh
|
||||||
|
deploy-daemon.sh
|
||||||
serve-pwa.sh
|
serve-pwa.sh
|
||||||
|
|
||||||
# Runtime config — contains anchor URL; copy from example and fill in
|
# Runtime config — contains anchor URL; copy from example and fill in
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 explewd
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
154
MULTI_ROOM_PROBLEM.md
Normal file
154
MULTI_ROOM_PROBLEM.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# The Multi-Room Peer ID Collision Problem
|
||||||
|
|
||||||
|
## What's happening
|
||||||
|
|
||||||
|
The flit daemon runs one goroutine per trusted peer, and each goroutine opens its own
|
||||||
|
WebSocket connection to the anchor and joins a separate pair room. Both connections
|
||||||
|
authenticate with the daemon's real Ed25519 identity — the same peer ID.
|
||||||
|
|
||||||
|
The anchor stores connected clients in a Go map keyed by peer ID:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// waste-go/cmd/anchor/main.go
|
||||||
|
type anchor struct {
|
||||||
|
clients map[string]*client // keyed by hex peer id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *anchor) register(c *client) {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.clients[c.id] = c // ← overwrites any existing entry for this id
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When the Samsung goroutine connects first, `clients["c639a691..."]` points to the Samsung
|
||||||
|
room WebSocket. When the Laptop goroutine connects a millisecond later, `clients["c639a691..."]`
|
||||||
|
is **overwritten** to point to the Laptop room WebSocket. The Samsung entry is gone.
|
||||||
|
|
||||||
|
The Samsung room WebSocket is still open. The daemon's read goroutine is still blocked on it.
|
||||||
|
But the anchor no longer routes any messages to it — it's orphaned. When Samsung joins,
|
||||||
|
`networkPeerIDs("e1d686b9...", samsung_id)` finds no entry for the daemon in that net hash,
|
||||||
|
returns an empty list, logs `peers=0`, and never sends the daemon a `peer-join`.
|
||||||
|
|
||||||
|
## Why it's not a bug in the anchor
|
||||||
|
|
||||||
|
The anchor was designed for human-scale use: one device, one room at a time. A single peer
|
||||||
|
ID being in two rooms simultaneously isn't a use case it was built for. The `map[string]*client`
|
||||||
|
design is correct for that model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
### Option A — Ephemeral signaling identity per session (recommended)
|
||||||
|
|
||||||
|
Generate a fresh Ed25519 keypair for each `dialSignaling` call. The anchor sees a unique,
|
||||||
|
throwaway ID per room. The real identity is only used inside the WebRTC DataChannel, in
|
||||||
|
the `hello` message where it matters for security.
|
||||||
|
|
||||||
|
**Anchor changes:** none.
|
||||||
|
|
||||||
|
**Protocol changes:** none at the DataChannel level (`hello` is unchanged).
|
||||||
|
|
||||||
|
**PWA change required:** the pair-room `trustedPeerID` filter currently rejects any
|
||||||
|
peer whose *signaling* ID doesn't match the expected peer ID. Since the daemon now
|
||||||
|
uses an ephemeral signaling ID, the PWA must not filter by signaling ID in pair-room
|
||||||
|
mode — instead, connect to the first peer in the room and rely on `hello` for identity
|
||||||
|
verification. This is safe:
|
||||||
|
|
||||||
|
- The pair room name is `hash("flit-pair:" + sorted(idA, idB))` — private to the two
|
||||||
|
parties; not publicly guessable unless you know both real peer IDs.
|
||||||
|
- `hello` binds the peer's real Ed25519 identity to the DTLS session, so an impostor
|
||||||
|
can't pass verification even if they discover the room name.
|
||||||
|
|
||||||
|
The trade-off: a peer in the wrong room (or a race-condition join) triggers a failed
|
||||||
|
hello instead of being silently ignored at the signaling level.
|
||||||
|
|
||||||
|
**Summary of code changes:**
|
||||||
|
- `dialSignaling`: accept a `*crypto.Identity` parameter (currently does); callers
|
||||||
|
pass a freshly-generated ephemeral identity instead of the real one.
|
||||||
|
- `Session.Join`: generate ephemeral identity before calling `dialSignaling`.
|
||||||
|
- `Session.PeerID()`: unchanged — returns the real identity's ID.
|
||||||
|
- `offerByOrder` comparison: compare real peer IDs (unchanged).
|
||||||
|
- PWA `transport/flit.ts`: in pair-room mode, remove signaling-level ID filter;
|
||||||
|
connect to any peer present in the room and let `hello` sort out identity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option B — Daemon-only room (single WS)
|
||||||
|
|
||||||
|
The daemon joins one room derived solely from its own real ID:
|
||||||
|
|
||||||
|
```
|
||||||
|
room = hash("flit-daemon:" + daemon_real_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
All trusted peers know this room (it's computable from the daemon's public ID). The
|
||||||
|
daemon filters incoming `peer-join` events against its trusted list. One WebSocket
|
||||||
|
connection, no collision.
|
||||||
|
|
||||||
|
**Anchor changes:** none.
|
||||||
|
|
||||||
|
**Protocol changes:** yes — pair-room name derivation changes for daemon mode. The PWA
|
||||||
|
needs a "connect to daemon" flow that uses `hash("flit-daemon:" + daemon_id)` instead
|
||||||
|
of `pairRoomName(myId, daemonId)`. Existing QR/pair-room flow is unaffected for
|
||||||
|
non-daemon peers.
|
||||||
|
|
||||||
|
**Trade-off:** all trusted peers share one room — each peer can see the others' peer IDs
|
||||||
|
(via `peer-join` events from the anchor). For most home setups this is fine; for higher
|
||||||
|
privacy requirements it's undesirable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option C — Per-peer sub-identity stored in config
|
||||||
|
|
||||||
|
For each trusted peer, the daemon config stores a unique Ed25519 keypair used only for
|
||||||
|
signaling into that peer's pair room. The real identity is still used for `hello`.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[peers]]
|
||||||
|
id = "ba3e38bf..." # peer's real ID
|
||||||
|
label = "Samsung"
|
||||||
|
signal_key = "aabb1122..." # daemon's per-peer signing key (hex Ed25519 private key)
|
||||||
|
```
|
||||||
|
|
||||||
|
The PWA must be told the daemon's per-peer signing key (its public half) to compute
|
||||||
|
the pair room and recognise the peer-join. This means re-pairing every time a new
|
||||||
|
`signal_key` is generated.
|
||||||
|
|
||||||
|
**Anchor changes:** none. **Protocol changes:** minor. **Pairing complexity:** high —
|
||||||
|
effectively adds a second identity per pair.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option D — Per-network derived identity (how waste-go solves this)
|
||||||
|
|
||||||
|
`waste-go/internal/netmgr` already has this problem and solves it cleanly:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// waste-go/internal/crypto/crypto.go
|
||||||
|
func DeriveForNetwork(master *Identity, networkHash string) (*Identity, error) {
|
||||||
|
r := hkdf.New(sha256.New, master.privateKey[:32], []byte(networkHash), []byte("yaw2-net-identity"))
|
||||||
|
var seed [32]byte
|
||||||
|
io.ReadFull(r, seed[:])
|
||||||
|
priv := ed25519.NewKeyFromSeed(seed[:])
|
||||||
|
return &Identity{privateKey: priv, ...}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`HKDF(master_private_key, network_hash)` produces a **deterministic, per-room keypair**.
|
||||||
|
Same master key + same room hash → same derived ID, always. Different rooms → different IDs.
|
||||||
|
No collision in the anchor's `clients` map.
|
||||||
|
|
||||||
|
**Anchor changes:** none.
|
||||||
|
**Protocol changes:** none — `hello` still uses the real identity.
|
||||||
|
**PWA changes:** none — the daemon's derived ID is stable per room, so the pair room
|
||||||
|
derivation and `trustedPeerID` filter are unaffected (they operate on the *other* peer's ID).
|
||||||
|
**Security:** same as before. The derived key signs the anchor challenge legitimately.
|
||||||
|
`hello` inside the DataChannel still uses and verifies the real Ed25519 identity.
|
||||||
|
|
||||||
|
This is the recommended fix. Flit's `crypto` package needs `DeriveForNetwork` added
|
||||||
|
(HKDF, same as waste-go), then `Session.Join` passes the derived identity to
|
||||||
|
`dialSignaling` instead of the master identity.
|
||||||
219
PROPOSAL-watch.md
Normal file
219
PROPOSAL-watch.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Proposal: `flit watch` — one-way folder sync over flit
|
||||||
|
|
||||||
|
**Status:** proposed, not built. Self-contained — written so a fresh agent
|
||||||
|
with no prior conversation context can pick this up and implement it
|
||||||
|
without anything explained first.
|
||||||
|
|
||||||
|
## Context (read this first)
|
||||||
|
|
||||||
|
Full background on flit is in [README.md](./README.md) and
|
||||||
|
[PROTOCOL.md](./PROTOCOL.md) — read both before touching code. The
|
||||||
|
relevant existing pieces for this proposal, all in `cli/`:
|
||||||
|
|
||||||
|
- `cmd/flit/main.go` — `send(ctx, path)`: creates a `transport.Session`,
|
||||||
|
generates a one-shot room + QR invite, and on `OnConnected` calls
|
||||||
|
`sess.SendFile(path)`. **`SendFile` is synchronous and blocking** — it
|
||||||
|
returns only once the transfer is fully complete (or errors). Its
|
||||||
|
return value alone is a sufficient "did this actually get there"
|
||||||
|
signal; no separate completion callback exists or is needed.
|
||||||
|
- `cmd/flit/daemon.go` — the pattern this proposal reuses almost
|
||||||
|
wholesale, just aimed the other direction:
|
||||||
|
- `daemonConfig`/`peerConfig` — TOML config (`~/.flit/daemon.toml`),
|
||||||
|
a list of trusted peers by `id`/`label`.
|
||||||
|
- `runPeerLoop(ctx, tc, myID, peer, downloadDir)` — maintains a
|
||||||
|
**persistent connection to one named peer**, reconnecting with
|
||||||
|
exponential backoff (2s → 30s cap) on disconnect. Sets
|
||||||
|
`OnFileOffer` to auto-accept anything the peer sends and save it to
|
||||||
|
`downloadDir`.
|
||||||
|
- This is receive-only today. **`flit watch` needs the send-side
|
||||||
|
sibling of this exact loop** — same persistent-connection/backoff
|
||||||
|
shape, but triggered by local filesystem events instead of incoming
|
||||||
|
offers.
|
||||||
|
- `internal/transport/transport.go` — `Session.Join(ctx, roomName,
|
||||||
|
trustedPeerID)`, `transport.PairRoomName(a, b)` (deterministic
|
||||||
|
pairwise room from two peer IDs — this is what makes "known device,
|
||||||
|
no fresh QR" work at all), `Session.SendFile(path)`,
|
||||||
|
`Session.PeerID()`.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
At one point the idea on the table was two-way directory sync — watch
|
||||||
|
a folder, keep it mirrored to a known host, in both directions. That
|
||||||
|
was deliberately walked back to **one-way sync**: source → destination
|
||||||
|
only, push only, never reconciling anything back. That sidesteps the
|
||||||
|
entire class of problem full bidirectional sync brings with it
|
||||||
|
(conflict resolution, delete propagation, catch-up ordering). The
|
||||||
|
moment this proposal starts growing bidirectional-anything, that's a
|
||||||
|
sign it's drifting toward re-implementing a general sync tool — flit's
|
||||||
|
own case study already draws that line explicitly ("Syncthing syncs
|
||||||
|
continuously; too heavy for ad hoc one-offs" — flit is deliberately
|
||||||
|
*not* trying to be that). This proposal should stay on the "one
|
||||||
|
watched folder pushes new files to one named peer, and that's the
|
||||||
|
whole feature" side of that line.
|
||||||
|
|
||||||
|
The actual want: drop a file into a watched folder, it gets encrypted
|
||||||
|
and pushed to a specific already-known device automatically — no
|
||||||
|
manual `flit send`, no QR, no re-running anything by hand per file.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. The receiving side needs no changes
|
||||||
|
|
||||||
|
Worth stating plainly since it simplifies scope: an already-running
|
||||||
|
`flit daemon`, configured with the sender as a trusted peer, already
|
||||||
|
does exactly the right thing on receipt — auto-accept, save to
|
||||||
|
`download_dir`. This proposal is entirely about the sending side.
|
||||||
|
|
||||||
|
### 2. `flit watch` — a new subcommand, new config file
|
||||||
|
|
||||||
|
`~/.flit/watch.toml`, same shape as `daemon.toml` deliberately, for
|
||||||
|
familiarity:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
signal_url = "wss://your-anchor.example.com/ws"
|
||||||
|
turn_url = ""
|
||||||
|
turn_secret = ""
|
||||||
|
|
||||||
|
[[watch]]
|
||||||
|
dir = "/home/user/backups/documents"
|
||||||
|
peer_id = "<hex Ed25519 pubkey of the destination device>"
|
||||||
|
peer_label = "home-server"
|
||||||
|
pattern = "*.pdf,*.docx" # optional glob include-list; default "*" if omitted
|
||||||
|
stable_after = "10s" # optional quiet period before considering a file ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple `[[watch]]` blocks are allowed — different folders can go to
|
||||||
|
different peers, or the same peer, independently.
|
||||||
|
|
||||||
|
### 3. Local state = the send queue, no separate queue structure needed
|
||||||
|
|
||||||
|
One state file per watch entry:
|
||||||
|
`~/.flit/watch-state/<sha256(dir + peer_id)>.json` —
|
||||||
|
`{ "<relative-path>": { "size": N, "mtime": N, "sha256": "...", "sent_at": N|null } }`.
|
||||||
|
|
||||||
|
**The invariant that makes this simple**: `sent_at` is only ever set
|
||||||
|
*after* `SendFile` returns without error. A file with `sent_at: null`,
|
||||||
|
or whose current size/mtime/hash no longer matches what's recorded, is
|
||||||
|
"needs sending" — that's the entire queue. No separate retry logic:
|
||||||
|
if `SendFile` fails partway (peer disconnects mid-transfer), the state
|
||||||
|
was never advanced, so the file is exactly as eligible for retry on
|
||||||
|
the next connection as it was before the attempt.
|
||||||
|
|
||||||
|
### 4. Startup: reconcile state against reality, don't trust only live events
|
||||||
|
|
||||||
|
On start, before subscribing to filesystem events, walk `dir`
|
||||||
|
(respecting `pattern`) and diff against the state file. This catches
|
||||||
|
anything that changed while the watcher wasn't running — a live-only
|
||||||
|
`fsnotify` subscription would silently miss files dropped in during
|
||||||
|
downtime.
|
||||||
|
|
||||||
|
### 5. The watch-and-send loop — `runPeerLoop`'s sibling
|
||||||
|
|
||||||
|
```go
|
||||||
|
// runWatchLoop mirrors daemon.go's runPeerLoop: a persistent connection
|
||||||
|
// to one named peer, reconnecting with the same backoff. Differs only in
|
||||||
|
// what drives sends — a local file-ready queue instead of OnFileOffer.
|
||||||
|
func runWatchLoop(ctx context.Context, tc transport.Config, myID string, w watchConfig, state *watchState) {
|
||||||
|
room := transport.PairRoomName(myID, w.PeerID)
|
||||||
|
backoff := 2 * time.Second
|
||||||
|
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess, err := transport.NewSession(dataDir(), tc)
|
||||||
|
// ... same connect/reconnect shape as runPeerLoop ...
|
||||||
|
sess.OnConnected = func(verified bool) {
|
||||||
|
backoff = 2 * time.Second
|
||||||
|
drainQueue(sess, w, state) // send everything currently queued, in order
|
||||||
|
}
|
||||||
|
// fsnotify events (and the startup reconciliation) push into the
|
||||||
|
// same queue; if already connected when a new file becomes ready,
|
||||||
|
// send it immediately rather than waiting for the next OnConnected.
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`drainQueue` sends queued files one at a time via `sess.SendFile(path)`
|
||||||
|
— sequential, not concurrent, since flit's session model is one peer
|
||||||
|
connection at a time and concurrent sends would need to be queued
|
||||||
|
DataChannel-side anyway (see "Multi-file send" in the flit case study
|
||||||
|
for the existing precedent of handling several files across one
|
||||||
|
session, just not built for a filesystem-driven trigger).
|
||||||
|
|
||||||
|
### 6. Debounce — don't send a file mid-write
|
||||||
|
|
||||||
|
`stable_after` is a quiet-period debounce: after the *last* fsnotify
|
||||||
|
write event for a given file, wait that long before considering it
|
||||||
|
ready. Without this, a large file being actively written would get
|
||||||
|
offered (and likely fail hash verification on the receiving end, or
|
||||||
|
worse, silently transfer a truncated version) the moment its first
|
||||||
|
bytes land. This is a real requirement, not a nice-to-have — flag it
|
||||||
|
as such if a fresh implementation is tempted to skip it for a first
|
||||||
|
cut.
|
||||||
|
|
||||||
|
### 7. Never touches deletes
|
||||||
|
|
||||||
|
The watcher only reacts to create/write events. A file deleted from
|
||||||
|
the source directory is never deleted from the destination, and never
|
||||||
|
should be — that's the entire point of one-way sync rather than a
|
||||||
|
mirror. Don't wire up `fsnotify`'s remove/rename events to anything
|
||||||
|
that deletes on the far end.
|
||||||
|
|
||||||
|
## Security considerations
|
||||||
|
|
||||||
|
- **This sends files with no per-file human confirmation** — a
|
||||||
|
deliberate, meaningful departure from interactive `flit send`
|
||||||
|
(always a conscious action) and even from `daemon`'s auto-accept
|
||||||
|
(still gated behind that peer being explicitly marked trusted with
|
||||||
|
`autoAccept` in the keyring). The safety knob here is entirely
|
||||||
|
`watch.toml` itself: which directories are named, and each entry's
|
||||||
|
`pattern`. Recommend a **`flit watch --dry-run`** mode that walks the
|
||||||
|
configured directories and prints what *would* be sent, without
|
||||||
|
connecting to anything — a way to sanity-check a new watch config
|
||||||
|
before it goes live and silently starts pushing files.
|
||||||
|
- **`pattern` should default to something narrower than `*` in
|
||||||
|
practice**, even though `*` is the technical default if omitted —
|
||||||
|
worth calling out in whatever docs ship with this, since an
|
||||||
|
unconstrained watch on a broad directory (e.g. a whole home folder)
|
||||||
|
turns "sync my documents" into "sync everything that ever lands in
|
||||||
|
this tree," which is a much bigger promise than most `[[watch]]`
|
||||||
|
entries are meant to make.
|
||||||
|
- **The destination is exactly as trusted as any other flit peer** —
|
||||||
|
this doesn't introduce a new trust primitive, it reuses the existing
|
||||||
|
Ed25519 identity + pairwise-room model as-is. A `peer_id` here is
|
||||||
|
the same kind of already-known, already-paired device as anything in
|
||||||
|
the PWA's "Known devices" list or `daemon.toml`.
|
||||||
|
- **No encryption-at-rest concern beyond what already exists** — files
|
||||||
|
are E2E encrypted in transit exactly like every other flit transfer;
|
||||||
|
this proposal doesn't change flit's crypto at all, only adds a new
|
||||||
|
trigger for when `SendFile` gets called.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Large initial backlogs** — if `dir` already has thousands of files
|
||||||
|
on first run, the startup reconciliation queues all of them at once.
|
||||||
|
Sequential sending (per §5) means this could take a long time before
|
||||||
|
catching up to "live." Acceptable for a first cut; a
|
||||||
|
`--max-startup-queue` or similar throttle could be added later if
|
||||||
|
this turns out to matter in practice — not designing it ahead of an
|
||||||
|
actual backlog being a problem.
|
||||||
|
- **Multiple watchers targeting the same peer simultaneously** — each
|
||||||
|
`[[watch]]` entry in §5's design opens its own `runWatchLoop`, meaning
|
||||||
|
its own separate connection/room to the same peer if two entries
|
||||||
|
share a `peer_id`. `transport.PairRoomName` is deterministic per
|
||||||
|
peer pair, so two simultaneous sessions to the same peer would
|
||||||
|
collide on the same room name — this needs either a single shared
|
||||||
|
connection per unique `peer_id` (multiple watch entries feeding one
|
||||||
|
`runWatchLoop`/queue) or confirmation that flit's transport already
|
||||||
|
tolerates multiple simultaneous sessions to the same room gracefully.
|
||||||
|
**Needs resolving before implementation, not deferred** — this is a
|
||||||
|
correctness question about the connection model, not a nice-to-have.
|
||||||
|
- **State file corruption/loss** — if `~/.flit/watch-state/*.json` is
|
||||||
|
lost (disk issue, manual deletion), the next startup reconciliation
|
||||||
|
will treat every file as unsent and re-push the entire directory.
|
||||||
|
Harmless for correctness (the destination just re-receives what it
|
||||||
|
already has) but potentially slow/wasteful for a large directory.
|
||||||
|
Worth a passing mention in user-facing docs, not worth engineering
|
||||||
|
around for a first version.
|
||||||
40
README.md
40
README.md
@@ -20,6 +20,7 @@ It is deliberately not a replacement for [Zipline](https://github.com/diced/zipl
|
|||||||
- Auto-downloads on the receiver side — no "click to save" step.
|
- Auto-downloads on the receiver side — no "click to save" step.
|
||||||
- In-app QR scanner (Chrome/Android) — scan the sender's QR without leaving flit.
|
- In-app QR scanner (Chrome/Android) — scan the sender's QR without leaving flit.
|
||||||
- Android share sheet integration — "Share → flit" works from Photos, Files, any app.
|
- Android share sheet integration — "Share → flit" works from Photos, Files, any app.
|
||||||
|
- One-way folder sync (`flit watch`) — drop a file into a watched folder, it gets pushed to a trusted peer automatically, no manual send.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,9 +53,9 @@ Each device generates an **Ed25519 keypair** on first run. The hex-encoded publi
|
|||||||
|
|
||||||
Pairing uses [yaw/2.1](PROTOCOL.md) forward-secret signaling, trimmed for flit's 1:1 ephemeral use case:
|
Pairing uses [yaw/2.1](PROTOCOL.md) forward-secret signaling, trimmed for flit's 1:1 ephemeral use case:
|
||||||
|
|
||||||
1. Connecting peers exchange **ephemeral X25519 keys** (`ekey`) signed with their Ed25519 identity keys — providing forward secrecy for the signaling channel.
|
1. Connecting peers exchange **ephemeral X25519 keys** (`ekey`) signed with their Ed25519 identity keys over the sealed signaling channel — this is what actually authenticates "the device on the other end is who the QR said it was."
|
||||||
2. Once the WebRTC data channel opens, each side sends a **`hello`** message containing their identity and a signature over a known prefix. This binds the WebRTC DTLS fingerprint to the Ed25519 identity, confirming "the device on the other end is who the QR said it was."
|
2. Once the WebRTC data channel opens, each side sends a **`hello`** message confirming its identity, plus a best-effort signature binding it to the session's DTLS fingerprint when both sides report that fingerprint under a hash algorithm flit can parse. `verified` status rests on step 1 (the authenticated sealed exchange) and `hello.id` matching it — not on the fingerprint signature, which different WebRTC stacks may report under different hash algorithms (WebKit: sha-512, Chromium/pion: sha-256) and so can't be relied on to match even between two legitimate peers.
|
||||||
3. File data flows over the WebRTC data channel — encrypted by DTLS, with the identity-confirmed binding from step 2.
|
3. File data flows over the WebRTC data channel — encrypted by DTLS, with the identity already confirmed by step 1.
|
||||||
|
|
||||||
The anchor never sees plaintext message content — it only routes sealed (encrypted) blobs by peer id and hashed room name.
|
The anchor never sees plaintext message content — it only routes sealed (encrypted) blobs by peer id and hashed room name.
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ For headless machines (homelab boxes, servers) that can't run a browser.
|
|||||||
flit send path/to/file.tar.gz # print QR + invite, wait for peer, send
|
flit send path/to/file.tar.gz # print QR + invite, wait for peer, send
|
||||||
flit recv "flit:eyJhbmNob3..." # join from invite string, accept file
|
flit recv "flit:eyJhbmNob3..." # join from invite string, accept file
|
||||||
flit daemon # persistent receiver for trusted peers
|
flit daemon # persistent receiver for trusted peers
|
||||||
|
flit watch [--dry-run] # watch folders, auto-push new files to trusted peers
|
||||||
```
|
```
|
||||||
|
|
||||||
**Built with:** Go, pion/webrtc, libsodium (via nhooyr.io/websocket + internal crypto).
|
**Built with:** Go, pion/webrtc, libsodium (via nhooyr.io/websocket + internal crypto).
|
||||||
@@ -132,6 +134,38 @@ Copy `cli/daemon.toml.example` to `~/.flit/daemon.toml` to get started. The daem
|
|||||||
|
|
||||||
**Bootstrapping:** to get a peer's `id`, pair once via QR (`flit send` / PWA "Invite new") and copy the hex ID from the "peer connected" log line, or read it from the PWA's Known devices list after tapping "Remember this device."
|
**Bootstrapping:** to get a peer's `id`, pair once via QR (`flit send` / PWA "Invite new") and copy the hex ID from the "peer connected" log line, or read it from the PWA's Known devices list after tapping "Remember this device."
|
||||||
|
|
||||||
|
### Watch mode
|
||||||
|
|
||||||
|
`flit watch` is the send-side sibling of `flit daemon`: it watches one or more local folders and automatically pushes new or changed files to a specific trusted peer — no manual `flit send`, no QR, no per-file interaction. It's **one-way sync only** (source → destination, push only) — deliberately not a general bidirectional sync tool, and it never propagates deletes.
|
||||||
|
|
||||||
|
The receiving side needs no changes: an already-running `flit daemon`, configured with the watcher's device as a trusted peer, already does the right thing on receipt.
|
||||||
|
|
||||||
|
Config lives at `~/.flit/watch.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
signal_url = "wss://your-anchor.example.com/ws"
|
||||||
|
turn_url = "" # optional
|
||||||
|
turn_secret = "" # optional
|
||||||
|
|
||||||
|
[[watch]]
|
||||||
|
dir = "/home/user/backups/documents"
|
||||||
|
peer_id = "<hex Ed25519 pubkey of the destination device>"
|
||||||
|
peer_label = "home-server"
|
||||||
|
pattern = "*.pdf,*.docx" # optional glob include-list; default "*" if omitted
|
||||||
|
stable_after = "10s" # optional quiet period before considering a file ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple `[[watch]]` blocks are allowed — different folders can go to different peers, or the same peer (entries sharing a `peer_id` share a single connection).
|
||||||
|
|
||||||
|
Copy `cli/watch.toml.example` to `~/.flit/watch.toml` to get started. Notes on behavior:
|
||||||
|
|
||||||
|
- **Startup reconciliation** — on start, before subscribing to filesystem events, `flit watch` walks each `dir` and diffs it against local state, so anything that changed while the watcher wasn't running gets picked up (not just live events going forward).
|
||||||
|
- **Debounce (`stable_after`)** — a file is only considered ready to send after this quiet period has passed since its last write, so a large file being actively written isn't offered mid-write.
|
||||||
|
- **State tracking** — one JSON state file per `[[watch]]` entry lives in `~/.flit/watch-state/`, recording what's been sent. If a send fails partway (peer disconnects mid-transfer), the file is retried on the next successful connection — no separate retry logic needed. Losing this state is harmless (worst case: the whole directory gets re-sent) but not something to engineer around.
|
||||||
|
- **`flit watch --dry-run`** walks the configured directories and prints what *would* be sent, without connecting to anything — use it to sanity-check a new `watch.toml` before it goes live.
|
||||||
|
- **`pattern` should be as narrow as your use case allows.** `*` is the default if omitted, but an unconstrained watch on a broad directory turns "sync my documents" into "sync everything that ever lands in this tree." There's no per-file confirmation in watch mode — the directory and pattern in `watch.toml` are the only safety knob.
|
||||||
|
- Reconnects with the same exponential backoff (2s → 30s cap) as daemon mode, and reuses the same trusted-peer identity model — a `peer_id` here is the same kind of already-known device as anything in the PWA's Known devices list or `daemon.toml`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ func runDaemon(ctx context.Context) {
|
|||||||
// reconnecting with exponential backoff whenever the connection drops.
|
// reconnecting with exponential backoff whenever the connection drops.
|
||||||
func runPeerLoop(ctx context.Context, tc transport.Config, myID string, peer peerConfig, downloadDir string) {
|
func runPeerLoop(ctx context.Context, tc transport.Config, myID string, peer peerConfig, downloadDir string) {
|
||||||
room := transport.PairRoomName(myID, peer.ID)
|
room := transport.PairRoomName(myID, peer.ID)
|
||||||
|
log.Printf("[%s] room: %s", peer.Label, room)
|
||||||
backoff := 2 * time.Second
|
backoff := 2 * time.Second
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// flit — headless CLI for ephemeral, E2E-encrypted file transfer.
|
// flit — headless CLI for ephemeral, E2E-encrypted file transfer.
|
||||||
//
|
//
|
||||||
|
// flit id print this device's peer ID (hex Ed25519 pubkey)
|
||||||
// flit send <path> generate a one-shot room, print QR + invite, wait for a peer, send
|
// flit send <path> generate a one-shot room, print QR + invite, wait for a peer, send
|
||||||
// flit recv <invite|code> join a room from an invite string, accept the offered file
|
// flit recv <invite|code> join a room from an invite string, accept the offered file
|
||||||
// flit daemon run as a persistent receiver for trusted peers (reads ~/.flit/daemon.toml)
|
// flit daemon run as a persistent receiver for trusted peers (reads ~/.flit/daemon.toml)
|
||||||
|
// flit watch [--dry-run] watch folders and auto-push new files to trusted peers (reads ~/.flit/watch.toml)
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -92,7 +94,7 @@ func cfg() transport.Config {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
fmt.Println("usage: flit send <path> | flit recv <invite> | flit daemon")
|
fmt.Println("usage: flit id | flit send <path> | flit recv <invite> | flit daemon | flit watch [--dry-run]")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +102,8 @@ func main() {
|
|||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
|
case "id":
|
||||||
|
printID()
|
||||||
case "send":
|
case "send":
|
||||||
if len(os.Args) < 3 {
|
if len(os.Args) < 3 {
|
||||||
fmt.Println("usage: flit send <path>")
|
fmt.Println("usage: flit send <path>")
|
||||||
@@ -114,12 +118,24 @@ func main() {
|
|||||||
recv(ctx, os.Args[2])
|
recv(ctx, os.Args[2])
|
||||||
case "daemon":
|
case "daemon":
|
||||||
runDaemon(ctx)
|
runDaemon(ctx)
|
||||||
|
case "watch":
|
||||||
|
dryRun := len(os.Args) > 2 && os.Args[2] == "--dry-run"
|
||||||
|
runWatch(ctx, dryRun)
|
||||||
default:
|
default:
|
||||||
fmt.Println("usage: flit send <path> | flit recv <invite> | flit daemon")
|
fmt.Println("usage: flit id | flit send <path> | flit recv <invite> | flit daemon | flit watch [--dry-run]")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func printID() {
|
||||||
|
sess, err := transport.NewSession(dataDir(), transport.Config{})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "flit:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println(sess.PeerID())
|
||||||
|
}
|
||||||
|
|
||||||
func send(ctx context.Context, path string) {
|
func send(ctx context.Context, path string) {
|
||||||
if _, err := os.Stat(path); err != nil {
|
if _, err := os.Stat(path); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "flit:", err)
|
fmt.Fprintln(os.Stderr, "flit:", err)
|
||||||
|
|||||||
532
cli/cmd/flit/watch.go
Normal file
532
cli/cmd/flit/watch.go
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
|
||||||
|
"flit/internal/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// watchFileConfig is read from ~/.flit/watch.toml.
|
||||||
|
type watchFileConfig struct {
|
||||||
|
SignalURL string `toml:"signal_url"`
|
||||||
|
TurnURL string `toml:"turn_url"`
|
||||||
|
TurnSecret string `toml:"turn_secret"`
|
||||||
|
Watches []watchEntryConfig `toml:"watch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type watchEntryConfig struct {
|
||||||
|
Dir string `toml:"dir"`
|
||||||
|
PeerID string `toml:"peer_id"`
|
||||||
|
PeerLabel string `toml:"peer_label"`
|
||||||
|
Pattern string `toml:"pattern"`
|
||||||
|
StableAfter string `toml:"stable_after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func watchConfigPath() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".flit", "watch.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func watchStateDir() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".flit", "watch-state")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadWatchConfig() (*watchFileConfig, error) {
|
||||||
|
path := watchConfigPath()
|
||||||
|
var cfg watchFileConfig
|
||||||
|
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if cfg.SignalURL == "" {
|
||||||
|
return nil, fmt.Errorf("%s: signal_url is required", path)
|
||||||
|
}
|
||||||
|
if len(cfg.Watches) == 0 {
|
||||||
|
return nil, fmt.Errorf("%s: no [[watch]] entries configured", path)
|
||||||
|
}
|
||||||
|
for i := range cfg.Watches {
|
||||||
|
w := &cfg.Watches[i]
|
||||||
|
if w.Dir == "" {
|
||||||
|
return nil, fmt.Errorf("%s: watch entry %d missing dir", path, i)
|
||||||
|
}
|
||||||
|
w.Dir = expandHome(w.Dir)
|
||||||
|
if w.PeerID == "" {
|
||||||
|
return nil, fmt.Errorf("%s: watch entry for %s missing peer_id", path, w.Dir)
|
||||||
|
}
|
||||||
|
if w.PeerLabel == "" {
|
||||||
|
w.PeerLabel = w.PeerID[:min16(w.PeerID)]
|
||||||
|
}
|
||||||
|
if w.Pattern == "" {
|
||||||
|
w.Pattern = "*"
|
||||||
|
}
|
||||||
|
if w.StableAfter == "" {
|
||||||
|
w.StableAfter = "10s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileState is the per-file record kept in a watch entry's state file.
|
||||||
|
// SentAt is nil until SendFile has returned successfully for exactly this
|
||||||
|
// size/mtime — that's the entire "needs sending" queue (see PROPOSAL-watch.md §3).
|
||||||
|
type fileState struct {
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Mtime int64 `json:"mtime"`
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
|
SentAt *int64 `json:"sent_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchEntry is one [[watch]] block: a directory watched for one peer.
|
||||||
|
type watchEntry struct {
|
||||||
|
cfg watchEntryConfig
|
||||||
|
stableAfter time.Duration
|
||||||
|
statePath string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
state map[string]fileState
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWatchEntry(cfg watchEntryConfig) (*watchEntry, error) {
|
||||||
|
d, err := time.ParseDuration(cfg.StableAfter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dir %s: invalid stable_after %q: %w", cfg.Dir, cfg.StableAfter, err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(cfg.Dir + cfg.PeerID))
|
||||||
|
e := &watchEntry{
|
||||||
|
cfg: cfg,
|
||||||
|
stableAfter: d,
|
||||||
|
statePath: filepath.Join(watchStateDir(), hex.EncodeToString(sum[:])+".json"),
|
||||||
|
state: map[string]fileState{},
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *watchEntry) load() error {
|
||||||
|
data, err := os.ReadFile(e.statePath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
return json.Unmarshal(data, &e.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *watchEntry) save() error {
|
||||||
|
e.mu.Lock()
|
||||||
|
data, err := json.MarshalIndent(e.state, "", " ")
|
||||||
|
e.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(e.statePath), 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(e.statePath, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// matches checks a file's base name against the entry's comma-separated
|
||||||
|
// glob pattern list.
|
||||||
|
func (e *watchEntry) matches(name string) bool {
|
||||||
|
for _, pat := range strings.Split(e.cfg.Pattern, ",") {
|
||||||
|
pat = strings.TrimSpace(pat)
|
||||||
|
if pat == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok, _ := filepath.Match(pat, name); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteObserved records the current size/mtime for relPath and, if they
|
||||||
|
// differ from what was last successfully sent (or nothing was ever sent),
|
||||||
|
// marks the file ready to send by clearing sent_at.
|
||||||
|
func (e *watchEntry) noteObserved(relPath string, size, mtime int64) (readyNow bool) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
cur, ok := e.state[relPath]
|
||||||
|
if ok && cur.SentAt != nil && cur.Size == size && cur.Mtime == mtime {
|
||||||
|
return false // already sent this exact version
|
||||||
|
}
|
||||||
|
e.state[relPath] = fileState{Size: size, Mtime: mtime, SHA256: cur.SHA256, SentAt: nil}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *watchEntry) markSent(relPath string, size, mtime int64, sha string) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
e.state[relPath] = fileState{Size: size, Mtime: mtime, SHA256: sha, SentAt: &now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingSorted returns relative paths currently marked as needing a send,
|
||||||
|
// in a deterministic order.
|
||||||
|
func (e *watchEntry) pendingSorted() []string {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
var out []string
|
||||||
|
for rel, st := range e.state {
|
||||||
|
if st.SentAt == nil {
|
||||||
|
out = append(out, rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcile walks the watched directory and updates in-memory state for
|
||||||
|
// anything that's new or changed since it was last recorded (PROPOSAL-watch.md §4).
|
||||||
|
// It does not touch the filesystem beyond reading; call save() afterward to persist.
|
||||||
|
func (e *watchEntry) reconcile() error {
|
||||||
|
entries, err := os.ReadDir(e.cfg.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, de := range entries {
|
||||||
|
if de.IsDir() || !e.matches(de.Name()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := de.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e.noteObserved(de.Name(), info.Size(), info.ModTime().Unix())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchPeerGroup shares one persistent connection across every [[watch]]
|
||||||
|
// entry targeting the same peer_id (PROPOSAL-watch.md open question #2).
|
||||||
|
type watchPeerGroup struct {
|
||||||
|
peerID string
|
||||||
|
label string
|
||||||
|
entries []*watchEntry
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
sess *transport.Session
|
||||||
|
|
||||||
|
sendMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *watchPeerGroup) currentSession() *transport.Session {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
return g.sess
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendOne sends one file and, on success, advances that file's state past
|
||||||
|
// "needs sending". On failure the state is left untouched, so the file
|
||||||
|
// remains eligible for retry on the next drain (PROPOSAL-watch.md §3).
|
||||||
|
func (g *watchPeerGroup) sendOne(sess *transport.Session, e *watchEntry, rel string) error {
|
||||||
|
g.sendMu.Lock()
|
||||||
|
defer g.sendMu.Unlock()
|
||||||
|
|
||||||
|
full := filepath.Join(e.cfg.Dir, rel)
|
||||||
|
info, err := os.Stat(full)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[%s] → %s", g.label, rel)
|
||||||
|
if err := sess.SendFile(full); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.markSent(rel, info.Size(), info.ModTime().Unix(), "")
|
||||||
|
if err := e.save(); err != nil {
|
||||||
|
log.Printf("[%s] warning: could not persist state for %s: %v", g.label, e.cfg.Dir, err)
|
||||||
|
}
|
||||||
|
log.Printf("[%s] ✓ sent %s", g.label, rel)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainAll sends every currently-pending file across every entry in the
|
||||||
|
// group, sequentially (PROPOSAL-watch.md §5). Stops early on the first
|
||||||
|
// send error, since that almost always means the connection just dropped;
|
||||||
|
// remaining files stay queued for the next successful connect.
|
||||||
|
func (g *watchPeerGroup) drainAll() {
|
||||||
|
sess := g.currentSession()
|
||||||
|
if sess == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range g.entries {
|
||||||
|
for _, rel := range e.pendingSorted() {
|
||||||
|
if g.currentSession() != sess {
|
||||||
|
return // disconnected mid-drain
|
||||||
|
}
|
||||||
|
if err := g.sendOne(sess, e, rel); err != nil {
|
||||||
|
log.Printf("[%s] send failed for %s: %v", g.label, rel, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attemptSendNow sends rel immediately if the group is currently connected.
|
||||||
|
// If not connected, the file simply stays marked pending — the next
|
||||||
|
// successful connect's drainAll will pick it up.
|
||||||
|
func (g *watchPeerGroup) attemptSendNow(e *watchEntry, rel string) {
|
||||||
|
sess := g.currentSession()
|
||||||
|
if sess == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := g.sendOne(sess, e, rel); err != nil {
|
||||||
|
log.Printf("[%s] send failed for %s: %v", g.label, rel, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runWatchGroup mirrors daemon.go's runPeerLoop: a persistent connection to
|
||||||
|
// one trusted peer, reconnecting with the same backoff. It differs only in
|
||||||
|
// what drives sends — pending state across the group's entries, rather than
|
||||||
|
// incoming OnFileOffer.
|
||||||
|
func runWatchGroup(ctx context.Context, tc transport.Config, myID string, g *watchPeerGroup) {
|
||||||
|
room := transport.PairRoomName(myID, g.peerID)
|
||||||
|
log.Printf("[%s] room: %s", g.label, room)
|
||||||
|
backoff := 2 * time.Second
|
||||||
|
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[%s] connecting…", g.label)
|
||||||
|
disconnected := make(chan struct{})
|
||||||
|
|
||||||
|
sess, err := transport.NewSession(dataDir(), tc)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[%s] session error: %v", g.label, err)
|
||||||
|
} else {
|
||||||
|
sess.OnConnected = func(verified bool) {
|
||||||
|
status := "unverified"
|
||||||
|
if verified {
|
||||||
|
status = "verified"
|
||||||
|
}
|
||||||
|
log.Printf("[%s] connected (%s)", g.label, status)
|
||||||
|
backoff = 2 * time.Second
|
||||||
|
g.mu.Lock()
|
||||||
|
g.sess = sess
|
||||||
|
g.mu.Unlock()
|
||||||
|
go g.drainAll()
|
||||||
|
}
|
||||||
|
sess.OnDisconnected = func() {
|
||||||
|
g.mu.Lock()
|
||||||
|
if g.sess == sess {
|
||||||
|
g.sess = nil
|
||||||
|
}
|
||||||
|
g.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-disconnected:
|
||||||
|
default:
|
||||||
|
close(disconnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sess.Join(ctx, room, g.peerID); err != nil {
|
||||||
|
log.Printf("[%s] join error: %v", g.label, err)
|
||||||
|
} else {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-disconnected:
|
||||||
|
log.Printf("[%s] disconnected", g.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
if backoff < 30*time.Second {
|
||||||
|
backoff *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchDirLive subscribes to filesystem events for one entry and, after a
|
||||||
|
// stable_after quiet period with no further writes to a given file, marks
|
||||||
|
// it ready and (if connected) sends it immediately. Deletes/renames are
|
||||||
|
// deliberately ignored (PROPOSAL-watch.md §7) — this is push-only, one-way.
|
||||||
|
func watchDirLive(ctx context.Context, e *watchEntry, g *watchPeerGroup) error {
|
||||||
|
w, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
if err := w.Add(e.cfg.Dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
timers := map[string]*time.Timer{}
|
||||||
|
|
||||||
|
fire := func(name string) {
|
||||||
|
full := filepath.Join(e.cfg.Dir, name)
|
||||||
|
info, err := os.Stat(full)
|
||||||
|
if err != nil {
|
||||||
|
return // removed again before it went stable
|
||||||
|
}
|
||||||
|
if e.noteObserved(name, info.Size(), info.ModTime().Unix()) {
|
||||||
|
_ = e.save()
|
||||||
|
g.attemptSendNow(e, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
mu.Lock()
|
||||||
|
for _, t := range timers {
|
||||||
|
t.Stop()
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
case ev, ok := <-w.Events:
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ev.Op&(fsnotify.Write|fsnotify.Create) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := filepath.Base(ev.Name)
|
||||||
|
if !e.matches(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if t, ok := timers[name]; ok {
|
||||||
|
t.Stop()
|
||||||
|
}
|
||||||
|
timers[name] = time.AfterFunc(e.stableAfter, func() { fire(name) })
|
||||||
|
mu.Unlock()
|
||||||
|
case err, ok := <-w.Errors:
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("watch %s: fsnotify error: %v", e.cfg.Dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWatch(ctx context.Context, dryRun bool) {
|
||||||
|
cfg, err := loadWatchConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "flit watch:", err)
|
||||||
|
fmt.Fprintf(os.Stderr, "create %s — see watch.toml.example\n", watchConfigPath())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]*watchEntry, 0, len(cfg.Watches))
|
||||||
|
for _, wc := range cfg.Watches {
|
||||||
|
if _, err := os.Stat(wc.Dir); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "flit watch: %s: %v\n", wc.Dir, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
e, err := newWatchEntry(wc)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "flit watch:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
for _, e := range entries {
|
||||||
|
if err := e.load(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "flit watch: %s: reading state: %v\n", e.cfg.Dir, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := e.reconcile(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "flit watch: %s: %v\n", e.cfg.Dir, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
pending := e.pendingSorted()
|
||||||
|
fmt.Printf("%s → %s (%d file(s) would be sent):\n", e.cfg.Dir, e.cfg.PeerLabel, len(pending))
|
||||||
|
for _, rel := range pending {
|
||||||
|
fmt.Printf(" %s\n", rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
if err := e.load(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "flit watch: %s: reading state: %v\n", e.cfg.Dir, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tc := transport.Config{SignalURL: cfg.SignalURL, TurnURL: cfg.TurnURL, TurnSecret: cfg.TurnSecret}
|
||||||
|
|
||||||
|
probe, err := transport.NewSession(dataDir(), tc)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "flit watch: identity error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
myID := probe.PeerID()
|
||||||
|
|
||||||
|
groups := map[string]*watchPeerGroup{}
|
||||||
|
var order []string
|
||||||
|
for _, e := range entries {
|
||||||
|
g, ok := groups[e.cfg.PeerID]
|
||||||
|
if !ok {
|
||||||
|
g = &watchPeerGroup{peerID: e.cfg.PeerID, label: e.cfg.PeerLabel}
|
||||||
|
groups[e.cfg.PeerID] = g
|
||||||
|
order = append(order, e.cfg.PeerID)
|
||||||
|
}
|
||||||
|
g.entries = append(g.entries, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("flit watch starting — id %s…", myID[:16])
|
||||||
|
log.Printf(" anchor : %s", cfg.SignalURL)
|
||||||
|
log.Printf(" dirs : %d, peers : %d", len(entries), len(groups))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, e := range entries {
|
||||||
|
if err := e.reconcile(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "flit watch: %s: %v\n", e.cfg.Dir, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := e.save(); err != nil {
|
||||||
|
log.Printf("warning: could not persist state for %s: %v", e.cfg.Dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, peerID := range order {
|
||||||
|
g := groups[peerID]
|
||||||
|
wg.Add(1)
|
||||||
|
go func(g *watchPeerGroup) {
|
||||||
|
defer wg.Done()
|
||||||
|
runWatchGroup(ctx, tc, myID, g)
|
||||||
|
}(g)
|
||||||
|
|
||||||
|
for _, e := range g.entries {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(e *watchEntry, g *watchPeerGroup) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := watchDirLive(ctx, e, g); err != nil {
|
||||||
|
log.Printf("watch %s: %v", e.cfg.Dir, err)
|
||||||
|
}
|
||||||
|
}(e, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
log.Println("flit watch stopped")
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
github.com/google/uuid v1.3.1 // indirect
|
github.com/google/uuid v1.3.1 // indirect
|
||||||
github.com/pion/datachannel v1.5.8 // indirect
|
github.com/pion/datachannel v1.5.8 // indirect
|
||||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||||
|
|||||||
@@ -8,17 +8,20 @@ package crypto
|
|||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/sha512"
|
"crypto/sha512"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"filippo.io/edwards25519"
|
"filippo.io/edwards25519"
|
||||||
"golang.org/x/crypto/curve25519"
|
"golang.org/x/crypto/curve25519"
|
||||||
|
"golang.org/x/crypto/hkdf"
|
||||||
"golang.org/x/crypto/nacl/box"
|
"golang.org/x/crypto/nacl/box"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,6 +97,21 @@ func Verify(publicKeyHex string, data []byte, sigHex string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeriveForNetwork returns a deterministic in-memory Identity derived from the
|
||||||
|
// master key and the given network hash (hex). Same inputs always produce the
|
||||||
|
// same keypair, so the peer ID is stable across restarts. Different network
|
||||||
|
// hashes produce different peer IDs, preventing the anchor's peer-ID map from
|
||||||
|
// colliding when the same device joins multiple rooms simultaneously.
|
||||||
|
func DeriveForNetwork(master *Identity, networkHash string) (*Identity, error) {
|
||||||
|
r := hkdf.New(sha256.New, master.privateKey[:32], []byte(networkHash), []byte("yaw2-net-identity"))
|
||||||
|
var seed [32]byte
|
||||||
|
if _, err := io.ReadFull(r, seed[:]); err != nil {
|
||||||
|
return nil, fmt.Errorf("deriving network identity: %w", err)
|
||||||
|
}
|
||||||
|
priv := ed25519.NewKeyFromSeed(seed[:])
|
||||||
|
return &Identity{privateKey: priv, PublicKey: priv.Public().(ed25519.PublicKey)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CurvePublicKey returns the X25519 public key derived from this Ed25519 identity.
|
// CurvePublicKey returns the X25519 public key derived from this Ed25519 identity.
|
||||||
func (id *Identity) CurvePublicKey() *[32]byte {
|
func (id *Identity) CurvePublicKey() *[32]byte {
|
||||||
edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey)
|
edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -105,6 +106,26 @@ func dialSignaling(ctx context.Context, url string, id *crypto.Identity, netHash
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
// Keepalive: ping every 20s so proxies/NAT don't silently drop the connection.
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(20 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
err := conn.Ping(pingCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
// Close so the read loop errors and OnDisconnected fires.
|
||||||
|
_ = conn.Close(websocket.StatusGoingAway, "ping failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
var msg anchorMsg
|
var msg anchorMsg
|
||||||
@@ -137,6 +158,7 @@ type FileOffer struct {
|
|||||||
XID string
|
XID string
|
||||||
Name string
|
Name string
|
||||||
Size int64
|
Size int64
|
||||||
|
SHA256 string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session is a 1:1 ephemeral pairing: join a room, connect to exactly one
|
// Session is a 1:1 ephemeral pairing: join a room, connect to exactly one
|
||||||
@@ -150,13 +172,17 @@ type Session struct {
|
|||||||
dc *webrtc.DataChannel
|
dc *webrtc.DataChannel
|
||||||
peerID string
|
peerID string
|
||||||
verified bool
|
verified bool
|
||||||
|
peerAuthed bool // set once a sealed box from peerID has been opened successfully
|
||||||
esk, epk *[32]byte
|
esk, epk *[32]byte
|
||||||
peerEPK *[32]byte
|
peerEPK *[32]byte
|
||||||
ekeySent bool
|
ekeySent bool
|
||||||
offered bool
|
offered bool
|
||||||
|
offerByOrder bool // true if this side should create the yaw DC and SDP offer
|
||||||
|
|
||||||
sig *signaling
|
sig *signaling
|
||||||
pendingRecv *recvState
|
pendingRecvs map[string]*recvState
|
||||||
|
pendingAccepts map[string]chan int64
|
||||||
|
pendingDones map[string]chan string // xid → channel receiving expected sha256 from file-done
|
||||||
|
|
||||||
OnConnected func(verified bool)
|
OnConnected func(verified bool)
|
||||||
OnDisconnected func()
|
OnDisconnected func()
|
||||||
@@ -179,7 +205,13 @@ func (s *Session) PeerID() string { return s.identity.PeerID() }
|
|||||||
// one peer (or the pinned trustedPeerID) to complete the handshake.
|
// one peer (or the pinned trustedPeerID) to complete the handshake.
|
||||||
func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID string) error {
|
func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID string) error {
|
||||||
hash := NetHash(roomName)
|
hash := NetHash(roomName)
|
||||||
sig, present, err := dialSignaling(ctx, s.cfg.SignalURL, s.identity, hash)
|
// Use a per-room derived identity for signaling so that joining multiple
|
||||||
|
// rooms simultaneously doesn't collide in the anchor's peer-ID map.
|
||||||
|
signingID, err := crypto.DeriveForNetwork(s.identity, hash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("derive signaling identity: %w", err)
|
||||||
|
}
|
||||||
|
sig, present, err := dialSignaling(ctx, s.cfg.SignalURL, signingID, hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -197,8 +229,18 @@ func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID strin
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
|
// 3-minute read deadline: if the anchor silently evicts us from
|
||||||
|
// the room (idle timeout) without closing the WS, we'll never
|
||||||
|
// receive a peer-join. Force a reconnect so we re-enter the room.
|
||||||
|
readCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||||
var msg anchorMsg
|
var msg anchorMsg
|
||||||
if err := wsjson.Read(ctx, sig.conn, &msg); err != nil {
|
err := wsjson.Read(readCtx, sig.conn, &msg)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
_ = sig.conn.Close(websocket.StatusGoingAway, "idle timeout")
|
||||||
|
if s.OnDisconnected != nil {
|
||||||
|
s.OnDisconnected()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
@@ -206,7 +248,16 @@ func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID strin
|
|||||||
if trustedPeerID != "" && msg.ID != trustedPeerID {
|
if trustedPeerID != "" && msg.ID != trustedPeerID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
log.Printf("flit: peer-join: %s", msg.ID[:8])
|
||||||
_ = s.connectTo(ctx, msg.ID)
|
_ = s.connectTo(ctx, msg.ID)
|
||||||
|
case "peer-left":
|
||||||
|
if trustedPeerID != "" && msg.ID != trustedPeerID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Log but don't reset: active file transfers use the WebRTC
|
||||||
|
// path which is independent of signaling. The stale-PC case
|
||||||
|
// is handled in connectTo when peer-join arrives next.
|
||||||
|
log.Printf("flit: peer-left: %s", msg.ID[:8])
|
||||||
case "from":
|
case "from":
|
||||||
if trustedPeerID != "" && msg.From != trustedPeerID {
|
if trustedPeerID != "" && msg.From != trustedPeerID {
|
||||||
continue
|
continue
|
||||||
@@ -220,45 +271,77 @@ func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID strin
|
|||||||
|
|
||||||
func (s *Session) connectTo(ctx context.Context, peerID string) error {
|
func (s *Session) connectTo(ctx context.Context, peerID string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
var oldPC *webrtc.PeerConnection
|
||||||
if s.pc != nil {
|
if s.pc != nil {
|
||||||
|
state := s.pc.ConnectionState()
|
||||||
|
if state == webrtc.PeerConnectionStateNew ||
|
||||||
|
state == webrtc.PeerConnectionStateConnected {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return nil
|
return nil // healthy or brand-new connection already exists
|
||||||
|
}
|
||||||
|
// Stale PC (peer left while we were connecting, or ICE failed) — replace it.
|
||||||
|
log.Printf("flit: connectTo %s: replacing stale PC (state=%s)", peerID[:8], state)
|
||||||
|
oldPC = s.pc
|
||||||
|
s.pc = nil
|
||||||
}
|
}
|
||||||
s.peerID = peerID
|
s.peerID = peerID
|
||||||
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers(s.cfg)})
|
var se webrtc.SettingEngine
|
||||||
|
se.DetachDataChannels()
|
||||||
|
api := webrtc.NewAPI(webrtc.WithSettingEngine(se))
|
||||||
|
pc, err := api.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers(s.cfg)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
if oldPC != nil {
|
||||||
|
_ = oldPC.Close()
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.pc = pc
|
s.pc = pc
|
||||||
kp, err := crypto.GenerateEphemeral()
|
kp, err := crypto.GenerateEphemeral()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.pc = nil
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
if oldPC != nil {
|
||||||
|
_ = oldPC.Close()
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.esk, s.epk = kp.PrivateRaw(), kp.PublicRaw()
|
s.esk, s.epk = kp.PrivateRaw(), kp.PublicRaw()
|
||||||
|
s.ekeySent = false
|
||||||
|
s.offered = false
|
||||||
|
s.peerEPK = nil
|
||||||
|
s.offerByOrder = s.identity.PeerID() < peerID
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Close the stale PC after s.pc has been updated. The Closed state callback
|
||||||
|
// will see isCurrent=false and skip OnDisconnected, so runPeerLoop keeps running.
|
||||||
|
if oldPC != nil {
|
||||||
|
_ = oldPC.Close()
|
||||||
|
}
|
||||||
|
|
||||||
pc.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) })
|
pc.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) })
|
||||||
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
if state == webrtc.PeerConnectionStateFailed ||
|
if state == webrtc.PeerConnectionStateFailed ||
|
||||||
state == webrtc.PeerConnectionStateDisconnected ||
|
state == webrtc.PeerConnectionStateDisconnected ||
|
||||||
state == webrtc.PeerConnectionStateClosed {
|
state == webrtc.PeerConnectionStateClosed {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
isCurrent := s.pc == pc
|
||||||
|
if isCurrent {
|
||||||
s.pc = nil
|
s.pc = nil
|
||||||
s.ekeySent = false
|
s.ekeySent = false
|
||||||
s.offered = false
|
s.offered = false
|
||||||
s.peerEPK = nil
|
s.peerEPK = nil
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if s.OnDisconnected != nil {
|
log.Printf("flit: PC state=%s isCurrent=%v", state, isCurrent)
|
||||||
|
if isCurrent && s.OnDisconnected != nil {
|
||||||
s.OnDisconnected()
|
s.OnDisconnected()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
s.sendEkey()
|
s.sendEkey()
|
||||||
offerByOrder := s.identity.PeerID() < peerID
|
if s.offerByOrder {
|
||||||
if offerByOrder {
|
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(ekeyTimeout)
|
time.Sleep(ekeyTimeout)
|
||||||
s.maybeOffer()
|
s.maybeOffer()
|
||||||
@@ -267,6 +350,23 @@ func (s *Session) connectTo(ctx context.Context, peerID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetPeer tears down the current WebRTC connection without triggering
|
||||||
|
// OnDisconnected, leaving the signaling connection open so that the next
|
||||||
|
// peer-join for this peer can reconnect immediately.
|
||||||
|
func (s *Session) resetPeer() {
|
||||||
|
s.mu.Lock()
|
||||||
|
pc := s.pc
|
||||||
|
s.pc = nil
|
||||||
|
s.ekeySent = false
|
||||||
|
s.offered = false
|
||||||
|
s.peerEPK = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
if pc != nil {
|
||||||
|
log.Printf("flit: resetPeer: closing stale PC (peer left signaling)")
|
||||||
|
_ = pc.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Session) sendEkey() {
|
func (s *Session) sendEkey() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.ekeySent {
|
if s.ekeySent {
|
||||||
@@ -290,7 +390,7 @@ func (s *Session) sendEkey() {
|
|||||||
|
|
||||||
func (s *Session) maybeOffer() {
|
func (s *Session) maybeOffer() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.offered || s.pc == nil {
|
if !s.offerByOrder || s.offered || s.pc == nil {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -324,6 +424,9 @@ func (s *Session) onBox(from, box string) {
|
|||||||
if plain == nil {
|
if plain == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.peerAuthed = true
|
||||||
|
s.mu.Unlock()
|
||||||
var obj map[string]any
|
var obj map[string]any
|
||||||
if err := json.Unmarshal(plain, &obj); err != nil {
|
if err := json.Unmarshal(plain, &obj); err != nil {
|
||||||
return
|
return
|
||||||
@@ -434,8 +537,32 @@ func (s *Session) wireDC(dc *webrtc.DataChannel) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.dc = dc
|
s.dc = dc
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
dc.OnOpen(func() { s.sendHello() })
|
|
||||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) { s.onControl(msg.Data) })
|
startYaw := func() {
|
||||||
|
raw, err := dc.Detach()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("flit: yaw detach: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sendHello()
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 32768)
|
||||||
|
for {
|
||||||
|
n, err := raw.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
s.onControl(append([]byte(nil), buf[:n]...))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if dc.ReadyState() == webrtc.DataChannelStateOpen {
|
||||||
|
startYaw()
|
||||||
|
} else {
|
||||||
|
dc.OnOpen(func() { startYaw() })
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(dc.Label(), "f:") {
|
if strings.HasPrefix(dc.Label(), "f:") {
|
||||||
@@ -444,10 +571,31 @@ func (s *Session) wireDC(dc *webrtc.DataChannel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) sendHello() {
|
func (s *Session) sendHello() {
|
||||||
s.controlSend(map[string]string{
|
s.mu.Lock()
|
||||||
"type": "hello", "id": s.identity.PeerID(),
|
pc := s.pc
|
||||||
"sig": s.identity.Sign([]byte(bindPrefix)),
|
s.mu.Unlock()
|
||||||
})
|
if pc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := map[string]string{
|
||||||
|
"type": "hello",
|
||||||
|
"id": s.identity.PeerID(),
|
||||||
|
}
|
||||||
|
// §6: sign prefix || local_fp || remote_fp when both are extractable.
|
||||||
|
// This is best-effort, not required — different WebRTC stacks report the
|
||||||
|
// DTLS certificate hash under different algorithms (WebKit: sha-512,
|
||||||
|
// pion/Chromium: sha-256), so a legitimate cross-stack peer may have no
|
||||||
|
// fingerprint we know how to parse. Trust rests on the sealed signaling
|
||||||
|
// exchange that already authenticated s.peerID (see onBox/peerAuthed)
|
||||||
|
// plus hello.id matching it, not on this signature — see onControl.
|
||||||
|
if lfp, err1 := dtlsFP(pc.LocalDescription().SDP); err1 == nil {
|
||||||
|
if rfp, err2 := dtlsFP(pc.RemoteDescription().SDP); err2 == nil {
|
||||||
|
bindMsg := append([]byte(bindPrefix), lfp...)
|
||||||
|
bindMsg = append(bindMsg, rfp...)
|
||||||
|
msg["sig"] = s.identity.Sign(bindMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.controlSend(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) onControl(data []byte) {
|
func (s *Session) onControl(data []byte) {
|
||||||
@@ -457,16 +605,71 @@ func (s *Session) onControl(data []byte) {
|
|||||||
}
|
}
|
||||||
switch m["type"] {
|
switch m["type"] {
|
||||||
case "hello":
|
case "hello":
|
||||||
|
helloID, _ := m["id"].(string)
|
||||||
|
sigHex, _ := m["sig"].(string)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.verified = m["id"] == s.peerID
|
pc := s.pc
|
||||||
|
peerAuthed := s.peerAuthed
|
||||||
|
s.mu.Unlock()
|
||||||
|
// Trust rests on the sealed signaling exchange that already
|
||||||
|
// authenticated s.peerID (peerAuthed, set in onBox) plus hello.id
|
||||||
|
// matching it — not on the fingerprint-bound signature below,
|
||||||
|
// which is only checkable when both sides report their DTLS cert
|
||||||
|
// hash under an algorithm we can parse. See sendHello for why
|
||||||
|
// that can't be a hard requirement.
|
||||||
|
verified := helloID == s.peerID && peerAuthed
|
||||||
|
if sigHex != "" && pc != nil && pc.RemoteDescription() != nil && pc.LocalDescription() != nil {
|
||||||
|
// §6: verifier reconstructs prefix || remote_fp || local_fp
|
||||||
|
// (sender's local = our remote; sender's remote = our local)
|
||||||
|
rfp, err1 := dtlsFP(pc.RemoteDescription().SDP)
|
||||||
|
lfp, err2 := dtlsFP(pc.LocalDescription().SDP)
|
||||||
|
if err1 == nil && err2 == nil {
|
||||||
|
bindMsg := append([]byte(bindPrefix), rfp...)
|
||||||
|
bindMsg = append(bindMsg, lfp...)
|
||||||
|
if err := crypto.Verify(helloID, bindMsg, sigHex); err != nil {
|
||||||
|
log.Printf("flit: hello: fingerprint-bind signature did not verify for %s (informational only): %v", helloID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.verified = verified
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if s.OnConnected != nil {
|
if s.OnConnected != nil {
|
||||||
s.OnConnected(s.verified)
|
s.OnConnected(verified)
|
||||||
}
|
}
|
||||||
case "file-offer":
|
case "file-offer":
|
||||||
if s.OnFileOffer != nil {
|
if s.OnFileOffer != nil {
|
||||||
size, _ := m["size"].(float64)
|
size, _ := m["size"].(float64)
|
||||||
s.OnFileOffer(FileOffer{XID: m["xid"].(string), Name: m["name"].(string), Size: int64(size)})
|
sha256, _ := m["sha256"].(string)
|
||||||
|
s.OnFileOffer(FileOffer{
|
||||||
|
XID: m["xid"].(string),
|
||||||
|
Name: m["name"].(string),
|
||||||
|
Size: int64(size),
|
||||||
|
SHA256: sha256,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "file-accept":
|
||||||
|
xid, _ := m["xid"].(string)
|
||||||
|
var offset int64
|
||||||
|
if v, ok := m["offset"].(string); ok {
|
||||||
|
offset, _ = strconv.ParseInt(v, 10, 64)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
ch := s.pendingAccepts[xid]
|
||||||
|
delete(s.pendingAccepts, xid)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if ch != nil {
|
||||||
|
ch <- offset
|
||||||
|
}
|
||||||
|
case "file-done":
|
||||||
|
xid, _ := m["xid"].(string)
|
||||||
|
sha256hex, _ := m["sha256"].(string)
|
||||||
|
s.mu.Lock()
|
||||||
|
ch := s.pendingDones[xid]
|
||||||
|
delete(s.pendingDones, xid)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if ch != nil {
|
||||||
|
ch <- sha256hex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,7 +685,9 @@ func (s *Session) controlSend(obj map[string]string) {
|
|||||||
_ = dc.SendText(string(data))
|
_ = dc.SendText(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendFile offers and streams a file to the connected peer.
|
// SendFile offers and streams a file to the connected peer. If the receiver
|
||||||
|
// has a partial .part file it will reply with a non-zero offset and the send
|
||||||
|
// resumes from that byte position.
|
||||||
func (s *Session) SendFile(path string) error {
|
func (s *Session) SendFile(path string) error {
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -496,7 +701,31 @@ func (s *Session) SendFile(path string) error {
|
|||||||
xid := fmt.Sprintf("push-%d", time.Now().UnixNano())
|
xid := fmt.Sprintf("push-%d", time.Now().UnixNano())
|
||||||
name := st.Name()
|
name := st.Name()
|
||||||
|
|
||||||
s.controlSend(map[string]string{"type": "file-offer", "name": name, "size": fmt.Sprint(st.Size()), "xid": xid})
|
// Compute sha256 over the whole file before offering (§9).
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return fmt.Errorf("hashing file: %w", err)
|
||||||
|
}
|
||||||
|
sha256hex := hex.EncodeToString(h.Sum(nil))
|
||||||
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return fmt.Errorf("seek after hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptCh := make(chan int64, 1)
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.pendingAccepts == nil {
|
||||||
|
s.pendingAccepts = make(map[string]chan int64)
|
||||||
|
}
|
||||||
|
s.pendingAccepts[xid] = acceptCh
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.controlSend(map[string]string{
|
||||||
|
"type": "file-offer",
|
||||||
|
"name": name,
|
||||||
|
"size": fmt.Sprint(st.Size()),
|
||||||
|
"xid": xid,
|
||||||
|
"sha256": sha256hex,
|
||||||
|
})
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
pc := s.pc
|
pc := s.pc
|
||||||
@@ -507,10 +736,24 @@ func (s *Session) SendFile(path string) error {
|
|||||||
}
|
}
|
||||||
opened := make(chan struct{})
|
opened := make(chan struct{})
|
||||||
dc.OnOpen(func() { close(opened) })
|
dc.OnOpen(func() { close(opened) })
|
||||||
|
|
||||||
|
var offset int64
|
||||||
|
select {
|
||||||
|
case offset = <-acceptCh:
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
return fmt.Errorf("timed out waiting for file-accept")
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case <-opened:
|
case <-opened:
|
||||||
case <-time.After(15 * time.Second):
|
case <-time.After(15 * time.Second):
|
||||||
return fmt.Errorf("timed out waiting for file-accept")
|
return fmt.Errorf("timed out waiting for data channel open")
|
||||||
|
}
|
||||||
|
|
||||||
|
if offset > 0 {
|
||||||
|
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
||||||
|
return fmt.Errorf("seek to resume offset: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("flit: resuming %s from byte %d", name, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := make([]byte, chunkSize)
|
buf := make([]byte, chunkSize)
|
||||||
@@ -531,15 +774,39 @@ func (s *Session) SendFile(path string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dc.Close()
|
// §9: send file-done before closing the file DC so the control channel
|
||||||
|
// is still reachable when the message is delivered.
|
||||||
|
s.controlSend(map[string]string{"type": "file-done", "xid": xid, "sha256": sha256hex})
|
||||||
|
if err := dc.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AcceptOffer accepts an incoming file-offer and writes the transfer to downloadDir.
|
// AcceptOffer accepts an incoming file-offer and writes the transfer to
|
||||||
|
// downloadDir. If a .part file from a previous partial transfer exists for this
|
||||||
|
// filename and is smaller than the declared size, its byte count is sent back
|
||||||
|
// as the resume offset so the sender can skip ahead.
|
||||||
func (s *Session) AcceptOffer(offer FileOffer, downloadDir string) {
|
func (s *Session) AcceptOffer(offer FileOffer, downloadDir string) {
|
||||||
s.controlSend(map[string]string{"type": "file-accept", "xid": offer.XID})
|
partPath := filepath.Join(downloadDir, offer.Name+".part")
|
||||||
|
var offset int64
|
||||||
|
if info, err := os.Stat(partPath); err == nil && info.Size() < offer.Size {
|
||||||
|
offset = info.Size()
|
||||||
|
}
|
||||||
|
// Register doneCh before sending file-accept so file-done can never arrive
|
||||||
|
// before the channel exists (small files finish before wireFileRecv runs).
|
||||||
|
doneCh := make(chan string, 1)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.pendingRecv = &recvState{offer: offer, dir: downloadDir}
|
if s.pendingDones == nil {
|
||||||
|
s.pendingDones = make(map[string]chan string)
|
||||||
|
}
|
||||||
|
s.pendingDones[offer.XID] = doneCh
|
||||||
|
if s.pendingRecvs == nil {
|
||||||
|
s.pendingRecvs = make(map[string]*recvState)
|
||||||
|
}
|
||||||
|
s.pendingRecvs[offer.XID] = &recvState{offer: offer, dir: downloadDir, have: offset, doneCh: doneCh}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
s.controlSend(map[string]string{"type": "file-accept", "xid": offer.XID, "offset": fmt.Sprint(offset)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) RejectOffer(xid string) {
|
func (s *Session) RejectOffer(xid string) {
|
||||||
@@ -551,40 +818,141 @@ type recvState struct {
|
|||||||
dir string
|
dir string
|
||||||
have int64
|
have int64
|
||||||
f *os.File
|
f *os.File
|
||||||
|
doneCh chan string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
||||||
xid := strings.TrimPrefix(dc.Label(), "f:")
|
xid := strings.TrimPrefix(dc.Label(), "f:")
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
rs := s.pendingRecv
|
rs := s.pendingRecvs[xid]
|
||||||
s.pendingRecv = nil
|
delete(s.pendingRecvs, xid)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if rs == nil || rs.offer.XID != xid {
|
if rs == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
path := rs.dir + "/" + rs.offer.Name
|
|
||||||
f, err := os.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("flit: create %s: %v", path, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rs.f = f
|
|
||||||
|
|
||||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
log.Printf("flit: wireFileRecv: %s size=%d state=%s", rs.offer.Name, rs.offer.Size, dc.ReadyState())
|
||||||
if _, err := f.Write(msg.Data); err != nil {
|
|
||||||
|
// Use pion's Detach API so data is buffered in pion's SCTP stream and
|
||||||
|
// read by our goroutine — no OnMessage/OnClose callback race.
|
||||||
|
startRecv := func() {
|
||||||
|
raw, err := dc.Detach()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("flit: file DC detach %s: %v", rs.offer.Name, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rs.have += int64(len(msg.Data))
|
log.Printf("flit: file DC open: %s", rs.offer.Name)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
finalPath := filepath.Join(rs.dir, rs.offer.Name)
|
||||||
|
resuming := rs.have > 0
|
||||||
|
|
||||||
|
var f *os.File
|
||||||
|
if resuming {
|
||||||
|
partPath := finalPath + ".part"
|
||||||
|
f, err = os.OpenFile(partPath, os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("flit: reopen %s: %v — restarting", partPath, err)
|
||||||
|
resuming = false
|
||||||
|
rs.have = 0
|
||||||
|
f, err = os.Create(finalPath)
|
||||||
|
} else {
|
||||||
|
log.Printf("flit: resuming %s from byte %d", rs.offer.Name, rs.have)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
f, err = os.Create(finalPath)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("flit: open output: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the already-received bytes first when resuming, so our
|
||||||
|
// running sha256 covers the whole file from byte 0.
|
||||||
|
h := sha256.New()
|
||||||
|
if resuming {
|
||||||
|
partPath := finalPath + ".part"
|
||||||
|
if pf, perr := os.Open(partPath); perr == nil {
|
||||||
|
_, _ = io.Copy(h, pf)
|
||||||
|
pf.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, chunkSize)
|
||||||
|
for {
|
||||||
|
n, readErr := raw.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, werr := f.Write(buf[:n]); werr != nil {
|
||||||
|
log.Printf("flit: write %s: %v", rs.offer.Name, werr)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
h.Write(buf[:n])
|
||||||
|
rs.have += int64(n)
|
||||||
if s.OnFileProgress != nil {
|
if s.OnFileProgress != nil {
|
||||||
s.OnFileProgress(xid, rs.have, rs.offer.Size)
|
s.OnFileProgress(xid, rs.have, rs.offer.Size)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
dc.OnClose(func() {
|
|
||||||
f.Close()
|
|
||||||
if s.OnFileDone != nil {
|
|
||||||
s.OnFileDone(rs.offer.Name, path)
|
|
||||||
}
|
}
|
||||||
})
|
if readErr != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
actualSHA256 := hex.EncodeToString(h.Sum(nil))
|
||||||
|
log.Printf("flit: recv done: %s wrote=%d of %d", rs.offer.Name, rs.have, rs.offer.Size)
|
||||||
|
|
||||||
|
if resuming {
|
||||||
|
partPath := finalPath + ".part"
|
||||||
|
if rerr := os.Rename(partPath, finalPath); rerr != nil {
|
||||||
|
log.Printf("flit: rename %s: %v", partPath, rerr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §9: wait for file-done and verify sha256 before reporting success.
|
||||||
|
// doneCh was registered in AcceptOffer before file-accept was sent,
|
||||||
|
// so file-done can never arrive before the channel exists.
|
||||||
|
doneCh := rs.doneCh
|
||||||
|
var expectedSHA256 string
|
||||||
|
select {
|
||||||
|
case expectedSHA256 = <-doneCh:
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
log.Printf("flit: file-done timeout for %s", rs.offer.Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if actualSHA256 != expectedSHA256 {
|
||||||
|
log.Printf("flit: sha256 mismatch for %s: got %s want %s", rs.offer.Name, actualSHA256, expectedSHA256)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.OnFileDone != nil {
|
||||||
|
s.OnFileDone(rs.offer.Name, finalPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if dc.ReadyState() == webrtc.DataChannelStateOpen {
|
||||||
|
startRecv()
|
||||||
|
} else {
|
||||||
|
dc.OnOpen(func() { startRecv() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dtlsFP extracts and hex-decodes the sha-256 DTLS fingerprint from an SDP blob.
|
||||||
|
// The spec (§6) requires this for the hello binding signature.
|
||||||
|
func dtlsFP(sdp string) ([]byte, error) {
|
||||||
|
const marker = "a=fingerprint:sha-256 "
|
||||||
|
for _, line := range strings.Split(sdp, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(line, marker) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fp, err := hex.DecodeString(strings.ReplaceAll(strings.TrimPrefix(line, marker), ":", ""))
|
||||||
|
if err != nil || len(fp) != 32 {
|
||||||
|
return nil, fmt.Errorf("bad DTLS fingerprint in SDP")
|
||||||
|
}
|
||||||
|
return fp, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no sha-256 DTLS fingerprint in SDP")
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustHex(s string) []byte {
|
func mustHex(s string) []byte {
|
||||||
|
|||||||
27
cli/watch.toml.example
Normal file
27
cli/watch.toml.example
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# flit watch config — copy to ~/.flit/watch.toml and fill in.
|
||||||
|
#
|
||||||
|
# Run with: flit watch
|
||||||
|
# Sanity-check what would be sent, without connecting to anything:
|
||||||
|
# flit watch --dry-run
|
||||||
|
#
|
||||||
|
# One-way sync only: new/changed files in `dir` are pushed to `peer_id`.
|
||||||
|
# Deletes are never propagated. The receiving device just needs to be
|
||||||
|
# running `flit daemon` with this device listed as a trusted peer.
|
||||||
|
|
||||||
|
# Required — WebSocket URL of your waste-go anchor
|
||||||
|
signal_url = "wss://your-anchor.example.com/ws"
|
||||||
|
|
||||||
|
# Optional — TURN relay for cross-network transfers
|
||||||
|
turn_url = "turn:your-anchor.example.com:3478"
|
||||||
|
turn_secret = "" # coturn use-auth-secret; leave empty to use STUN only
|
||||||
|
|
||||||
|
# One [[watch]] block per watched directory. Multiple entries may target
|
||||||
|
# different peers, or the same peer — entries sharing a peer_id share one
|
||||||
|
# connection.
|
||||||
|
|
||||||
|
[[watch]]
|
||||||
|
dir = "~/backups/documents"
|
||||||
|
peer_id = "aabbccddeeff..." # hex Ed25519 pubkey of the destination device
|
||||||
|
peer_label = "home-server"
|
||||||
|
pattern = "*.pdf,*.docx" # comma-separated glob include-list; default "*" if omitted
|
||||||
|
stable_after = "10s" # quiet period after the last write before a file is considered ready
|
||||||
24
pwa/package-lock.json
generated
24
pwa/package-lock.json
generated
@@ -611,6 +611,29 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
|
||||||
@@ -676,7 +699,6 @@
|
|||||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "pwa",
|
"name": "pwa",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -106,6 +106,45 @@
|
|||||||
background: rgba(255,107,107,0.08);
|
background: rgba(255,107,107,0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-accent {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:hover {
|
||||||
|
background: rgba(0,232,122,0.2);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-id-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-id-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-id-row .peer-id {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-id-row .btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-link {
|
.btn-link {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -200,9 +239,8 @@
|
|||||||
|
|
||||||
.known-row {
|
.known-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 10px;
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -212,6 +250,14 @@
|
|||||||
|
|
||||||
.known-row:hover { border-color: var(--accent-border); }
|
.known-row:hover { border-color: var(--accent-border); }
|
||||||
|
|
||||||
|
.known-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.known-info {
|
.known-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -232,18 +278,35 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.presence-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-online { background: var(--accent); box-shadow: 0 0 4px var(--accent); }
|
||||||
|
.presence-offline { background: var(--muted); opacity: 0.5; }
|
||||||
|
.presence-unknown { background: var(--muted); opacity: 0.25; }
|
||||||
|
|
||||||
.peer-id {
|
.peer-id {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.known-actions {
|
.known-secondary {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.known-connect {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── QR / hosting card ───────────────────────────────────────────────────────── */
|
/* ── QR / hosting card ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.qr-card {
|
.qr-card {
|
||||||
|
|||||||
147
pwa/src/App.tsx
147
pwa/src/App.tsx
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { FlitSession, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FileSendProgressEvent, type FileSentEvent, type FlitConfig } from './transport/flit'
|
import { FlitSession, queryPresence, netHash, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FileSendProgressEvent, type FileSentEvent, type FlitConfig } from './transport/flit'
|
||||||
import { createInvite, decodeInvite, pairRoomName } from './pairing/ephemeral'
|
import { createInvite, decodeInvite, pairRoomName } from './pairing/ephemeral'
|
||||||
import { QrScanner } from './pairing/QrScanner'
|
import { QrScanner } from './pairing/QrScanner'
|
||||||
import { addTrusted, listTrusted, removeTrusted, type TrustedPeer } from './pairing/keyring'
|
import { addTrusted, listTrusted, removeTrusted, setAutoAccept, shouldAutoAccept, type TrustedPeer } from './pairing/keyring'
|
||||||
import { consumeSharedFiles, registerServiceWorker } from './share-target'
|
import { consumeSharedFiles, registerServiceWorker } from './share-target'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
@@ -39,7 +39,12 @@ function App() {
|
|||||||
const [sendProgress, setSendProgress] = useState<FileSendProgressEvent | null>(null)
|
const [sendProgress, setSendProgress] = useState<FileSendProgressEvent | null>(null)
|
||||||
const [sentFiles, setSentFiles] = useState<FileSentEvent[]>([])
|
const [sentFiles, setSentFiles] = useState<FileSentEvent[]>([])
|
||||||
const [scanning, setScanning] = useState(false)
|
const [scanning, setScanning] = useState(false)
|
||||||
|
const [myId, setMyId] = useState<string | null>(null)
|
||||||
|
const [presence, setPresence] = useState<Record<string, boolean | null>>({})
|
||||||
|
const [addIdInput, setAddIdInput] = useState('')
|
||||||
|
const [copied, setCopied] = useState<'snip' | 'id' | null>(null)
|
||||||
const sessionRef = useRef<FlitSession | null>(null)
|
const sessionRef = useRef<FlitSession | null>(null)
|
||||||
|
const verifiedRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerServiceWorker()
|
registerServiceWorker()
|
||||||
@@ -48,6 +53,10 @@ function App() {
|
|||||||
if (files.length) void sendFiles(files)
|
if (files.length) void sendFiles(files)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Load own identity eagerly so we can show/copy our peer ID
|
||||||
|
try {
|
||||||
|
FlitSession.init(cfg()).then(s => setMyId(s.identity.id)).catch(() => {})
|
||||||
|
} catch { /* config not ready yet */ }
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -57,11 +66,44 @@ function App() {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Presence check for known devices (waste-go EXT-009). Each pairing has its
|
||||||
|
// own deterministic anchor room, so this is one short-lived query per
|
||||||
|
// device — no persistent connection held while idle.
|
||||||
|
async function refreshPresence() {
|
||||||
|
if (!myId || trusted.length === 0) return
|
||||||
|
const signalURL = cfg().signalURL
|
||||||
|
const results = await Promise.all(trusted.map(async (p) => {
|
||||||
|
try {
|
||||||
|
const hash = await netHash(pairRoomName(myId, p.id))
|
||||||
|
const online = await queryPresence(signalURL, hash, p.id)
|
||||||
|
return [p.id, online] as const
|
||||||
|
} catch {
|
||||||
|
return [p.id, null] as const
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
setPresence(Object.fromEntries(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'known' || phase !== 'idle' || !myId || trusted.length === 0) return
|
||||||
|
void refreshPresence()
|
||||||
|
const iv = setInterval(() => void refreshPresence(), 15000)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mode, phase, myId, trusted])
|
||||||
|
|
||||||
function peerEvents() {
|
function peerEvents() {
|
||||||
return {
|
return {
|
||||||
connected: (v: boolean, pid: string) => { setVerified(v); setPeerId(pid); setPhase('connected') },
|
connected: (v: boolean, pid: string) => { setVerified(v); verifiedRef.current = v; setPeerId(pid); setPhase('connected') },
|
||||||
status: (s: RTCPeerConnectionState) => setConnState(s),
|
status: (s: RTCPeerConnectionState) => setConnState(s),
|
||||||
fileOffer: (e: FileOfferEvent) => { setOffers((o) => [...o, e]); setPeerId(e.peer) },
|
fileOffer: (e: FileOfferEvent) => {
|
||||||
|
setPeerId(e.peer)
|
||||||
|
if (verifiedRef.current && shouldAutoAccept(e.peer)) {
|
||||||
|
sessionRef.current?.acceptOffer(e.xid, e.name, e.size)
|
||||||
|
} else {
|
||||||
|
setOffers((o) => [...o, e])
|
||||||
|
}
|
||||||
|
},
|
||||||
fileProgress: (e: FileProgressEvent) => setProgress(e),
|
fileProgress: (e: FileProgressEvent) => setProgress(e),
|
||||||
fileRecv: (e: FileRecvEvent) => {
|
fileRecv: (e: FileRecvEvent) => {
|
||||||
setReceived((r) => [...r, e])
|
setReceived((r) => [...r, e])
|
||||||
@@ -162,6 +204,58 @@ function App() {
|
|||||||
setTrusted(listTrusted())
|
setTrusted(listTrusted())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleAutoAccept(peer: TrustedPeer) {
|
||||||
|
setAutoAccept(peer.id, !peer.autoAccept)
|
||||||
|
setTrusted(listTrusted())
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
sessionRef.current?.close()
|
||||||
|
sessionRef.current = null
|
||||||
|
setPhase('idle')
|
||||||
|
setVerified(false)
|
||||||
|
verifiedRef.current = false
|
||||||
|
setPeerId('')
|
||||||
|
setOffers([])
|
||||||
|
setProgress(null)
|
||||||
|
setSendProgress(null)
|
||||||
|
setConnState(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPeerById() {
|
||||||
|
const raw = addIdInput.trim()
|
||||||
|
if (!raw) return
|
||||||
|
// Accept bare hex ID or flit-peer:<base64> snip
|
||||||
|
let id = raw
|
||||||
|
let suggestedLabel = ''
|
||||||
|
if (raw.startsWith('flit-peer:')) {
|
||||||
|
try {
|
||||||
|
const b64 = raw.slice('flit-peer:'.length).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const parsed = JSON.parse(atob(b64)) as { id: string; label?: string }
|
||||||
|
id = parsed.id
|
||||||
|
suggestedLabel = parsed.label ?? ''
|
||||||
|
} catch { setError('Invalid flit-peer snip'); return }
|
||||||
|
}
|
||||||
|
if (!/^[0-9a-f]{64}$/i.test(id)) { setError('Not a valid peer ID (64 hex chars)'); return }
|
||||||
|
const label = window.prompt('Label for this device', suggestedLabel || id.slice(0, 8))
|
||||||
|
if (label === null) return
|
||||||
|
addTrusted(id, label.trim() || id.slice(0, 8))
|
||||||
|
setTrusted(listTrusted())
|
||||||
|
setAddIdInput('')
|
||||||
|
setMode('known')
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyMyId(mode: 'snip' | 'id') {
|
||||||
|
if (!myId) return
|
||||||
|
const text = mode === 'snip'
|
||||||
|
? 'flit-peer:' + btoa(JSON.stringify({ id: myId })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
: myId
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(mode)
|
||||||
|
setTimeout(() => setCopied(null), 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="app">
|
||||||
<h1>flit<span className="dot">.</span></h1>
|
<h1>flit<span className="dot">.</span></h1>
|
||||||
@@ -190,15 +284,28 @@ function App() {
|
|||||||
: <ul className="known-list">
|
: <ul className="known-list">
|
||||||
{trusted.map(p => (
|
{trusted.map(p => (
|
||||||
<li key={p.id} className="known-row">
|
<li key={p.id} className="known-row">
|
||||||
|
<div className="known-top">
|
||||||
<div className="known-info">
|
<div className="known-info">
|
||||||
<span className="known-label">{p.label}</span>
|
<span className="known-label">
|
||||||
|
<span
|
||||||
|
className={`presence-dot ${presence[p.id] === true ? 'presence-online' : presence[p.id] === false ? 'presence-offline' : 'presence-unknown'}`}
|
||||||
|
title={presence[p.id] === true ? 'Online' : presence[p.id] === false ? 'Offline' : 'Unknown'}
|
||||||
|
/>
|
||||||
|
{p.label}
|
||||||
|
</span>
|
||||||
<span className="peer-id">{p.id.slice(0, 16)}…</span>
|
<span className="peer-id">{p.id.slice(0, 16)}…</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="known-actions">
|
<div className="known-secondary">
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => connectToKnown(p)}>Connect</button>
|
<button
|
||||||
|
className={`btn btn-sm ${p.autoAccept ? 'btn-accent' : ''}`}
|
||||||
|
title="Auto-accept files from this device when verified"
|
||||||
|
onClick={() => toggleAutoAccept(p)}
|
||||||
|
>{p.autoAccept ? 'Auto ✓' : 'Auto'}</button>
|
||||||
<button className="btn btn-sm" onClick={() => renameKnown(p)}>Rename</button>
|
<button className="btn btn-sm" onClick={() => renameKnown(p)}>Rename</button>
|
||||||
<button className="btn btn-sm btn-danger" onClick={() => forgetKnown(p)}>Forget</button>
|
<button className="btn btn-sm btn-danger" onClick={() => forgetKnown(p)}>Forget</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary known-connect" onClick={() => connectToKnown(p)}>Connect</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -218,6 +325,29 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<button className="btn" onClick={() => joinFromInvite(inviteInput)}>Join</button>
|
<button className="btn" onClick={() => joinFromInvite(inviteInput)}>Join</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="divider">or add known peer</div>
|
||||||
|
<div className="join-row">
|
||||||
|
<input
|
||||||
|
className="text-input"
|
||||||
|
placeholder="peer ID or flit-peer: snip"
|
||||||
|
value={addIdInput}
|
||||||
|
onChange={(e) => setAddIdInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn" onClick={addPeerById}>Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{myId && (
|
||||||
|
<div className="my-id-row">
|
||||||
|
<span className="my-id-label">your ID</span>
|
||||||
|
<span className="peer-id">{myId.slice(0, 16)}…</span>
|
||||||
|
<button className="btn btn-sm" onClick={() => copyMyId('id')}>
|
||||||
|
{copied === 'id' ? 'Copied ✓' : 'Copy ID'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm" onClick={() => copyMyId('snip')}>
|
||||||
|
{copied === 'snip' ? 'Copied ✓' : 'Copy snip'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -251,7 +381,10 @@ function App() {
|
|||||||
</span>
|
</span>
|
||||||
{peerId && <span className="peer-id">{peerId.slice(0, 16)}…</span>}
|
{peerId && <span className="peer-id">{peerId.slice(0, 16)}…</span>}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="btn-row">
|
||||||
<button className="btn" onClick={trustCurrentPeer}>Remember this device</button>
|
<button className="btn" onClick={trustCurrentPeer}>Remember this device</button>
|
||||||
|
<button className="btn btn-danger" onClick={disconnect}>Disconnect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="dropzone">
|
<label className="dropzone">
|
||||||
Tap to choose file(s) to send
|
Tap to choose file(s) to send
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface TrustedPeer {
|
|||||||
id: string // hex Ed25519 pubkey
|
id: string // hex Ed25519 pubkey
|
||||||
label: string
|
label: string
|
||||||
addedAt: number
|
addedAt: number
|
||||||
|
autoAccept?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = 'flit_keyring'
|
const KEY = 'flit_keyring'
|
||||||
@@ -19,6 +20,11 @@ export function addTrusted(id: string, label: string): void {
|
|||||||
localStorage.setItem(KEY, JSON.stringify(peers))
|
localStorage.setItem(KEY, JSON.stringify(peers))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setAutoAccept(id: string, value: boolean): void {
|
||||||
|
const peers = listTrusted().map(p => p.id === id ? { ...p, autoAccept: value } : p)
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(peers))
|
||||||
|
}
|
||||||
|
|
||||||
export function removeTrusted(id: string): void {
|
export function removeTrusted(id: string): void {
|
||||||
localStorage.setItem(KEY, JSON.stringify(listTrusted().filter(p => p.id !== id)))
|
localStorage.setItem(KEY, JSON.stringify(listTrusted().filter(p => p.id !== id)))
|
||||||
}
|
}
|
||||||
@@ -26,3 +32,7 @@ export function removeTrusted(id: string): void {
|
|||||||
export function isTrusted(id: string): boolean {
|
export function isTrusted(id: string): boolean {
|
||||||
return listTrusted().some(p => p.id === id)
|
return listTrusted().some(p => p.id === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldAutoAccept(id: string): boolean {
|
||||||
|
return listTrusted().some(p => p.id === id && p.autoAccept === true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ async function iceServers(cfg: FlitConfig): Promise<RTCIceServer[]> {
|
|||||||
|
|
||||||
const enc = (s: string) => new TextEncoder().encode(s)
|
const enc = (s: string) => new TextEncoder().encode(s)
|
||||||
|
|
||||||
|
// dtlsFP extracts and hex-decodes the sha-256 DTLS fingerprint from an SDP string.
|
||||||
|
function dtlsFP(sdp: string): Uint8Array {
|
||||||
|
const m = sdp.match(/a=fingerprint:sha-256 ([\dA-Fa-f:]+)/i)
|
||||||
|
if (!m) throw new Error('no sha-256 DTLS fingerprint in SDP')
|
||||||
|
return sodium.from_hex(m[1].replace(/:/g, ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256hex(buf: ArrayBuffer): Promise<string> {
|
||||||
|
const digest = await globalThis.crypto.subtle.digest('SHA-256', buf)
|
||||||
|
return toHex(new Uint8Array(digest))
|
||||||
|
}
|
||||||
|
|
||||||
function concat(...arrs: Uint8Array[]): Uint8Array {
|
function concat(...arrs: Uint8Array[]): Uint8Array {
|
||||||
const n = arrs.reduce((a, b) => a + b.length, 0)
|
const n = arrs.reduce((a, b) => a + b.length, 0)
|
||||||
const out = new Uint8Array(n); let o = 0
|
const out = new Uint8Array(n); let o = 0
|
||||||
@@ -67,6 +79,38 @@ export async function netHash(name: string): Promise<string> {
|
|||||||
throw new Error('SHA-256 not available in this browser runtime')
|
throw new Error('SHA-256 not available in this browser runtime')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryPresence asks the anchor whether `id` is currently connected in
|
||||||
|
// network `net` (waste-go EXT-009). Opens a short-lived WS connection —
|
||||||
|
// no join, no identity signature required, since the query is anchor-local
|
||||||
|
// and scoped to a (net, id) pair the caller must already know. Resolves
|
||||||
|
// `null` on timeout/error (treat the same as "unknown", not "offline").
|
||||||
|
export function queryPresence(url: string, net: string, id: string, timeoutMs = 4000): Promise<boolean | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const ws = new WebSocket(url)
|
||||||
|
const finish = (result: boolean | null) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
try { ws.close() } catch { /* ignore */ }
|
||||||
|
resolve(result)
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||||
|
ws.onopen = () => {
|
||||||
|
try { ws.send(JSON.stringify({ type: 'presence_query', net, id })) } catch { finish(null) }
|
||||||
|
}
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let m: Record<string, unknown>
|
||||||
|
try { m = JSON.parse(ev.data) } catch { return }
|
||||||
|
if (m['type'] === 'presence' && m['id'] === id && m['net'] === net) {
|
||||||
|
finish(Boolean(m['online']))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.onerror = () => finish(null)
|
||||||
|
ws.onclose = () => finish(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Identity ─────────────────────────────────────────────────────────────────
|
// ── Identity ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class Identity {
|
export class Identity {
|
||||||
@@ -250,25 +294,31 @@ export class PeerConn {
|
|||||||
private _offerPending = false
|
private _offerPending = false
|
||||||
private _offered = false
|
private _offered = false
|
||||||
|
|
||||||
private _recv: Record<string, { name: string; size: number; buf: ArrayBuffer[]; have: number; cancelled?: boolean }> = {}
|
private _recv: Record<string, { name: string; size: number; buf: ArrayBuffer[]; have: number; cancelled?: boolean; dataComplete?: boolean; expectedSHA256?: string }> = {}
|
||||||
private _recvChannels: Record<string, RTCDataChannel> = {}
|
private _recvChannels: Record<string, RTCDataChannel> = {}
|
||||||
private _pushQueue: Map<string, File> = new Map()
|
private _pushQueue: Map<string, { file: File; sha256: string; buf: ArrayBuffer }> = new Map()
|
||||||
|
|
||||||
private identity: Identity
|
private identity: Identity
|
||||||
private sig: Signaling
|
private sig: Signaling
|
||||||
|
// signalingId: the peer's anchor routing ID (may be a per-room derived key)
|
||||||
|
// cryptoId / peerId: the peer's real Ed25519 identity (used for seal/open and hello)
|
||||||
|
private _signalingId: string
|
||||||
peerId: string
|
peerId: string
|
||||||
|
|
||||||
private events: Partial<PeerEvents>
|
private events: Partial<PeerEvents>
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
identity: Identity,
|
identity: Identity,
|
||||||
sig: Signaling,
|
sig: Signaling,
|
||||||
peerId: string,
|
signalingId: string,
|
||||||
|
cryptoId: string,
|
||||||
events: Partial<PeerEvents>,
|
events: Partial<PeerEvents>,
|
||||||
ice: RTCIceServer[],
|
ice: RTCIceServer[],
|
||||||
) {
|
) {
|
||||||
this.identity = identity
|
this.identity = identity
|
||||||
this.sig = sig
|
this.sig = sig
|
||||||
this.peerId = peerId
|
this._signalingId = signalingId
|
||||||
|
this.peerId = cryptoId
|
||||||
this.events = events
|
this.events = events
|
||||||
this.pc = new RTCPeerConnection({ iceServers: ice })
|
this.pc = new RTCPeerConnection({ iceServers: ice })
|
||||||
const kp = sodium.crypto_box_keypair()
|
const kp = sodium.crypto_box_keypair()
|
||||||
@@ -303,7 +353,7 @@ export class PeerConn {
|
|||||||
this._ekeySent = true
|
this._ekeySent = true
|
||||||
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.identity.id), sodium.from_hex(this.peerId), this._epk)
|
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.identity.id), sodium.from_hex(this.peerId), this._epk)
|
||||||
const msg = { kind: 'ekey', v: 'yaw/2.1', epk: sodium.to_hex(this._epk), sig: sodium.to_hex(this.identity.sign(signed)) }
|
const msg = { kind: 'ekey', v: 'yaw/2.1', epk: sodium.to_hex(this._epk), sig: sodium.to_hex(this.identity.sign(signed)) }
|
||||||
this.sig.sendTo(this.peerId, this._seal(msg, false))
|
this.sig.sendTo(this._signalingId, this._seal(msg, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _onEkey(obj: Record<string, unknown>) {
|
private async _onEkey(obj: Record<string, unknown>) {
|
||||||
@@ -333,7 +383,7 @@ export class PeerConn {
|
|||||||
this._wire(this.dc)
|
this._wire(this.dc)
|
||||||
await this.pc.setLocalDescription(await this.pc.createOffer())
|
await this.pc.setLocalDescription(await this.pc.createOffer())
|
||||||
await gatherComplete(this.pc)
|
await gatherComplete(this.pc)
|
||||||
this.sig.sendTo(this.peerId, this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true))
|
this.sig.sendTo(this._signalingId, this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true))
|
||||||
}
|
}
|
||||||
|
|
||||||
async onBox(box: string) {
|
async onBox(box: string) {
|
||||||
@@ -349,7 +399,7 @@ export class PeerConn {
|
|||||||
await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string })
|
await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string })
|
||||||
await this.pc.setLocalDescription(await this.pc.createAnswer())
|
await this.pc.setLocalDescription(await this.pc.createAnswer())
|
||||||
await gatherComplete(this.pc)
|
await gatherComplete(this.pc)
|
||||||
this.sig.sendTo(this.peerId, this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph))
|
this.sig.sendTo(this._signalingId, this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph))
|
||||||
} else if (obj['kind'] === 'answer') {
|
} else if (obj['kind'] === 'answer') {
|
||||||
await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string })
|
await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string })
|
||||||
}
|
}
|
||||||
@@ -376,17 +426,32 @@ export class PeerConn {
|
|||||||
}
|
}
|
||||||
channel.onclose = () => {
|
channel.onclose = () => {
|
||||||
if (rx.cancelled) { delete this._recv[xid]; return }
|
if (rx.cancelled) { delete this._recv[xid]; return }
|
||||||
const blob = new Blob(rx.buf)
|
rx.dataComplete = true
|
||||||
const url = URL.createObjectURL(blob)
|
// If file-done already arrived before DC closed, finalize now.
|
||||||
this.events.fileRecv?.({ peer: this.peerId, name: rx.name, size: rx.size, url })
|
if (rx.expectedSHA256 !== undefined) void this._finalizeRecv(xid)
|
||||||
delete this._recv[xid]
|
|
||||||
}
|
}
|
||||||
this._recvChannels[xid] = channel
|
this._recvChannels[xid] = channel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _sendHello() {
|
private _sendHello() {
|
||||||
this._dc({ type: 'hello', id: this.identity.id, sig: sodium.to_hex(this.identity.sign(enc(BIND_PREFIX))) })
|
// §6: sign prefix || local_fp || remote_fp when both are extractable.
|
||||||
|
// Best-effort, not required — different WebRTC stacks report the DTLS
|
||||||
|
// certificate hash under different algorithms (WebKit: sha-512,
|
||||||
|
// pion/Chromium: sha-256), so a legitimate cross-stack peer may have no
|
||||||
|
// fingerprint we know how to parse. Trust rests on the sealed signaling
|
||||||
|
// exchange that already authenticated peerId (peerAuthed, set in onBox)
|
||||||
|
// plus hello.id matching it, not on this signature — see _onControl.
|
||||||
|
const msg: Record<string, string> = { type: 'hello', id: this.identity.id }
|
||||||
|
try {
|
||||||
|
const lfp = dtlsFP(this.pc.localDescription!.sdp)
|
||||||
|
const rfp = dtlsFP(this.pc.remoteDescription!.sdp)
|
||||||
|
const bindMsg = concat(enc(BIND_PREFIX), lfp, rfp)
|
||||||
|
msg['sig'] = sodium.to_hex(this.identity.sign(bindMsg))
|
||||||
|
} catch {
|
||||||
|
// no parseable fingerprint on one or both sides — hello still goes out
|
||||||
|
}
|
||||||
|
this._dc(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onControl(data: string) {
|
private _onControl(data: string) {
|
||||||
@@ -394,13 +459,37 @@ export class PeerConn {
|
|||||||
try { m = JSON.parse(data) } catch { return }
|
try { m = JSON.parse(data) } catch { return }
|
||||||
|
|
||||||
if (m['type'] === 'hello') {
|
if (m['type'] === 'hello') {
|
||||||
this.verified = m['id'] === this.peerId && this.peerAuthed
|
const helloId = m['id'] as string
|
||||||
|
// Trust rests on the sealed signaling exchange that already
|
||||||
|
// authenticated peerId (peerAuthed, set in onBox) plus hello.id
|
||||||
|
// matching it — not on the fingerprint-bound signature below, which
|
||||||
|
// is only checkable when both sides report their DTLS cert hash
|
||||||
|
// under an algorithm we can parse. See _sendHello for why that
|
||||||
|
// can't be a hard requirement.
|
||||||
|
this.verified = helloId === this.peerId && this.peerAuthed
|
||||||
|
const sigHex = m['sig'] as string | undefined
|
||||||
|
if (sigHex) {
|
||||||
|
try {
|
||||||
|
// §6: verifier reconstructs prefix || remote_fp || local_fp
|
||||||
|
// (sender's local = our remote; sender's remote = our local)
|
||||||
|
const rfp = dtlsFP(this.pc.remoteDescription!.sdp)
|
||||||
|
const lfp = dtlsFP(this.pc.localDescription!.sdp)
|
||||||
|
const bindMsg = concat(enc(BIND_PREFIX), rfp, lfp)
|
||||||
|
const helloSig = sodium.from_hex(sigHex)
|
||||||
|
if (!Identity.verify(helloId, bindMsg, helloSig)) {
|
||||||
|
console.warn(`flit: hello: fingerprint-bind signature did not verify for ${helloId} (informational only)`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no parseable fingerprint on one or both sides — nothing to check
|
||||||
|
}
|
||||||
|
}
|
||||||
this.events.connected?.(this.verified, this.peerId)
|
this.events.connected?.(this.verified, this.peerId)
|
||||||
} else if (m['type'] === 'file-offer') {
|
} else if (m['type'] === 'file-offer') {
|
||||||
this.events.fileOffer?.({ peer: this.peerId, xid: m['xid'] as string, name: m['name'] as string, size: m['size'] as number })
|
this.events.fileOffer?.({ peer: this.peerId, xid: m['xid'] as string, name: m['name'] as string, size: m['size'] as number })
|
||||||
} else if (m['type'] === 'file-accept') {
|
} else if (m['type'] === 'file-accept') {
|
||||||
const xid = m['xid'] as string
|
const xid = m['xid'] as string
|
||||||
if (this._pushQueue.has(xid)) void this._stream(xid)
|
const offset = typeof m['offset'] === 'string' ? parseInt(m['offset'] as string, 10) || 0 : 0
|
||||||
|
if (this._pushQueue.has(xid)) void this._stream(xid, offset)
|
||||||
} else if (m['type'] === 'file-cancel') {
|
} else if (m['type'] === 'file-cancel') {
|
||||||
const xid = m['xid'] as string
|
const xid = m['xid'] as string
|
||||||
if (this._recv[xid]) {
|
if (this._recv[xid]) {
|
||||||
@@ -409,8 +498,34 @@ export class PeerConn {
|
|||||||
delete this._recvChannels[xid]
|
delete this._recvChannels[xid]
|
||||||
this.events.fileCancelled?.(xid)
|
this.events.fileCancelled?.(xid)
|
||||||
}
|
}
|
||||||
|
} else if (m['type'] === 'file-done') {
|
||||||
|
const xid = m['xid'] as string
|
||||||
|
const expectedSHA256 = m['sha256'] as string
|
||||||
|
const rx = this._recv[xid]
|
||||||
|
if (rx) {
|
||||||
|
rx.expectedSHA256 = expectedSHA256
|
||||||
|
// If DC already closed and all data is in, finalize now.
|
||||||
|
if (rx.dataComplete) void this._finalizeRecv(xid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _finalizeRecv(xid: string) {
|
||||||
|
const rx = this._recv[xid]
|
||||||
|
if (!rx) return
|
||||||
|
delete this._recv[xid]
|
||||||
|
delete this._recvChannels[xid]
|
||||||
|
const combined = new Uint8Array(rx.buf.reduce((acc, b) => acc + b.byteLength, 0))
|
||||||
|
let offset = 0
|
||||||
|
for (const b of rx.buf) { combined.set(new Uint8Array(b), offset); offset += b.byteLength }
|
||||||
|
const actualSHA256 = await sha256hex(combined.buffer)
|
||||||
|
if (actualSHA256 !== rx.expectedSHA256) {
|
||||||
|
console.error(`flit: sha256 mismatch for ${rx.name}: got ${actualSHA256} want ${rx.expectedSHA256}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(new Blob([combined]))
|
||||||
|
this.events.fileRecv?.({ peer: this.peerId, name: rx.name, size: rx.size, url })
|
||||||
|
}
|
||||||
|
|
||||||
acceptOffer(xid: string, name: string, size: number) {
|
acceptOffer(xid: string, name: string, size: number) {
|
||||||
this._recv[xid] = { name, size, buf: [], have: 0 }
|
this._recv[xid] = { name, size, buf: [], have: 0 }
|
||||||
@@ -421,31 +536,34 @@ export class PeerConn {
|
|||||||
this._dc({ type: 'file-cancel', xid })
|
this._dc({ type: 'file-cancel', xid })
|
||||||
}
|
}
|
||||||
|
|
||||||
sendFile(file: File) {
|
async sendFile(file: File) {
|
||||||
const xid = `push-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
const xid = `push-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
this._pushQueue.set(xid, file)
|
const buf = await file.arrayBuffer()
|
||||||
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid })
|
const hash = await sha256hex(buf)
|
||||||
|
this._pushQueue.set(xid, { file, sha256: hash, buf })
|
||||||
|
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid, sha256: hash })
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _stream(xid: string) {
|
private async _stream(xid: string, resumeOffset = 0) {
|
||||||
const file = this._pushQueue.get(xid)
|
const entry = this._pushQueue.get(xid)
|
||||||
if (!file) return
|
if (!entry) return
|
||||||
this._pushQueue.delete(xid)
|
this._pushQueue.delete(xid)
|
||||||
|
const { file, sha256: hash, buf: fullBuf } = entry
|
||||||
const dc = this.pc.createDataChannel(`f:${xid}`)
|
const dc = this.pc.createDataChannel(`f:${xid}`)
|
||||||
dc.binaryType = 'arraybuffer'
|
dc.binaryType = 'arraybuffer'
|
||||||
await new Promise<void>(res => { dc.onopen = () => res() })
|
await new Promise<void>(res => { dc.onopen = () => res() })
|
||||||
const buf = await file.arrayBuffer()
|
const buf = resumeOffset > 0 ? fullBuf.slice(resumeOffset) : fullBuf
|
||||||
let offset = 0
|
let localOffset = 0
|
||||||
while (offset < buf.byteLength) {
|
while (localOffset < buf.byteLength) {
|
||||||
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
|
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
|
||||||
dc.send(buf.slice(offset, offset + CHUNK))
|
dc.send(buf.slice(localOffset, localOffset + CHUNK))
|
||||||
offset += CHUNK
|
localOffset += CHUNK
|
||||||
this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: offset, total: buf.byteLength })
|
this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: resumeOffset + localOffset, total: file.size })
|
||||||
// Yield to the event loop every chunk so the browser can paint
|
|
||||||
// progress — without this, small files finish in one synchronous
|
|
||||||
// tick and the UI never shows intermediate state.
|
|
||||||
await new Promise(r => setTimeout(r, 0))
|
await new Promise(r => setTimeout(r, 0))
|
||||||
}
|
}
|
||||||
|
// §9: send file-done before closing the file DC so the control channel
|
||||||
|
// is still healthy when the message is dispatched.
|
||||||
|
this._dc({ type: 'file-done', xid, sha256: hash })
|
||||||
dc.close()
|
dc.close()
|
||||||
this.events.fileSent?.({ peer: this.peerId, xid, name: file.name })
|
this.events.fileSent?.({ peer: this.peerId, xid, name: file.name })
|
||||||
}
|
}
|
||||||
@@ -488,27 +606,34 @@ export class FlitSession {
|
|||||||
this.sig = sig
|
this.sig = sig
|
||||||
const ice = await iceServers(this.cfg)
|
const ice = await iceServers(this.cfg)
|
||||||
|
|
||||||
const connectTo = async (pid: string) => {
|
const connectTo = async (signalingId: string) => {
|
||||||
if (pid === this.identity.id) return
|
if (signalingId === this.identity.id) return
|
||||||
if (trustedPeerId && pid !== trustedPeerId) return
|
|
||||||
if (this.peer) return
|
if (this.peer) return
|
||||||
this.peer = new PeerConn(this.identity, sig, pid, events, ice)
|
// cryptoId is the real Ed25519 identity used for seal/open and hello.
|
||||||
if (this.identity.id < pid) await this.peer.startOffer()
|
// In ephemeral rooms trustedPeerId is unset so signalingId == cryptoId.
|
||||||
|
// In pair rooms the daemon uses a derived signaling ID; trustedPeerId is
|
||||||
|
// the real ID we verify hello against.
|
||||||
|
const cryptoId = trustedPeerId ?? signalingId
|
||||||
|
this.peer = new PeerConn(this.identity, sig, signalingId, cryptoId, events, ice)
|
||||||
|
// Compare real IDs so both sides agree on who offers first.
|
||||||
|
if (this.identity.id < cryptoId) await this.peer.startOffer()
|
||||||
}
|
}
|
||||||
|
|
||||||
const present = await sig.connect(
|
const present = await sig.connect(
|
||||||
(from, box) => { void this._onFrom(from, box, events, ice, trustedPeerId) },
|
(from, box) => { void this._onFrom(from, box, events, ice, trustedPeerId) },
|
||||||
(pid) => { void connectTo(pid) },
|
(signalingId) => { void connectTo(signalingId) },
|
||||||
() => { /* peer-leave: nothing to clean up for a single 1:1 session */ },
|
() => { /* peer-leave: nothing to clean up for a single 1:1 session */ },
|
||||||
(peers) => { peers.forEach(pid => void connectTo(pid)) },
|
(peers) => { peers.forEach(signalingId => void connectTo(signalingId)) },
|
||||||
)
|
)
|
||||||
for (const pid of present) await connectTo(pid)
|
for (const signalingId of present) await connectTo(signalingId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _onFrom(from: string, box: string, events: Partial<PeerEvents>, ice: RTCIceServer[], trustedPeerId?: string) {
|
private async _onFrom(from: string, box: string, events: Partial<PeerEvents>, ice: RTCIceServer[], trustedPeerId?: string) {
|
||||||
if (from === this.identity.id) return
|
if (from === this.identity.id) return
|
||||||
if (trustedPeerId && from !== trustedPeerId) return
|
if (!this.peer) {
|
||||||
if (!this.peer) this.peer = new PeerConn(this.identity, this.sig!, from, events, ice)
|
const cryptoId = trustedPeerId ?? from
|
||||||
|
this.peer = new PeerConn(this.identity, this.sig!, from, cryptoId, events, ice)
|
||||||
|
}
|
||||||
await this.peer.onBox(box)
|
await this.peer.onBox(box)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user