Migrate to YAW/2 protocol: WebRTC transport, anchor signaling, nacl/box crypto
Replace the original raw-TCP peer connections and Blowfish/RSA-era design with the YAW/2 protocol stack: Transport - internal/mesh/peer.go: raw TCP + manual ECDH replaced with pion/webrtc DataChannels (ICE, DTLS, SCTP). Peers negotiate via sealed offer/answer/ candidate exchange rather than direct dialling. - internal/nat: deleted — ICE subsumes everything this package was going to do. Signaling - cmd/anchor: new WebSocket signaling server (replaces cmd/relay). Verifies Ed25519 challenge/join signatures, routes opaque nacl/box blobs, broadcasts peer-join/peer-leave events. Never sees plaintext signaling content. - internal/anchor: new anchor client. Manages PeerConnection lifecycle, decides offerer by peer-id comparison, seals/opens signaling payloads with nacl/box, implements mesh.Anchor. Crypto (internal/crypto) - PeerID encoding: base64url → lowercase hex (64 chars, YAW/2 §2). - Sign/Verify: hex signatures throughout. - Added CurvePublicKey/CurvePrivateKey: X25519 keys derived from Ed25519 identity via Montgomery conversion (filippo.io/edwards25519), matching libsodium's crypto_sign_ed25519_*_to_curve25519. - Added SignalingBox/SignalingOpen: nacl/box (XSalsa20-Poly1305) seal/open for signaling payloads. Wire types (internal/proto) - ChatMessage gains Mid (random 16-byte hex) for mesh deduplication (§8). - FileChunk removed from PeerMessage — chunks go on a separate binary DataChannel labelled "f:<xid>"; FileDone added. - New types: SignalingPayload, AnchorMessage, HelloMessage, HelloBindString. - PeerID.Short() now returns first 16 hex chars grouped in 4s. - IPC: CmdConnect removed; CmdJoinNetwork/CmdLeaveNetwork added; EvtSessionReady added (fires when DataChannel opens and hello is received). IPC (internal/ipc) - join_network/leave_network replace connect (direct TCP dial). - Network joins are context-scoped: leave_network or client disconnect cancels the anchor connection cleanly. README updated to reflect new project layout, getting-started commands, IPC protocol, and roadmap state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
425
MIGRATION.md
Normal file
425
MIGRATION.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# Migrating waste-go → YAW/2
|
||||
|
||||
This document describes every change needed to make waste-go speak the YAW/2
|
||||
protocol and interoperate with the reference implementation. Read it top to
|
||||
bottom before touching any code — the changes have an order that matters.
|
||||
|
||||
---
|
||||
|
||||
## What stays, what goes, what changes
|
||||
|
||||
| Package | Fate | Reason |
|
||||
|---|---|---|
|
||||
| `internal/crypto` | **Keep, modify** | Ed25519 is correct; swap encoding + add nacl/box |
|
||||
| `internal/proto` | **Keep, extend** | Message shapes are close; add `mid`, tweak file transfer |
|
||||
| `internal/mesh` (state) | **Keep, modify** | Peer map, event fan-out, IPC subscriptions all survive |
|
||||
| `internal/mesh/peer.go` | **Replace** | Raw TCP → WebRTC DataChannel |
|
||||
| `internal/ipc` | **Keep, minor tweaks** | IPC shape doesn't change |
|
||||
| `internal/nat` | **Delete** | ICE/STUN inside pion/webrtc replaces this entirely |
|
||||
| `cmd/relay` | **Replace** | TCP forwarder → WebSocket signaling anchor |
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — New dependency: pion/webrtc
|
||||
|
||||
Add to `go.mod`:
|
||||
|
||||
```
|
||||
github.com/pion/webrtc/v3 v3.x.x
|
||||
golang.org/x/crypto (already present)
|
||||
```
|
||||
|
||||
Run `go mod tidy` after editing.
|
||||
|
||||
`pion/webrtc` is a pure-Go implementation of the WebRTC stack. It gives you ICE
|
||||
(host + server-reflexive candidates), DTLS, SCTP, and DataChannels — everything
|
||||
YAW/2's transport layer assumes. You no longer implement any of that yourself.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — `internal/crypto`: three changes
|
||||
|
||||
### 2a. Identity encoding: base64url → hex
|
||||
|
||||
YAW/2 `id` is lowercase hex of the 32-byte Ed25519 public key (64 chars).
|
||||
|
||||
```go
|
||||
// Before
|
||||
import "encoding/base64"
|
||||
var b64 = base64.RawURLEncoding
|
||||
func (id *Identity) PeerID() proto.PeerID {
|
||||
return proto.PeerID(b64.EncodeToString(id.PublicKey))
|
||||
}
|
||||
|
||||
// After
|
||||
import "encoding/hex"
|
||||
func (id *Identity) PeerID() proto.PeerID {
|
||||
return proto.PeerID(hex.EncodeToString(id.PublicKey))
|
||||
}
|
||||
```
|
||||
|
||||
Update `Verify` to decode hex instead of base64url. This is a **wire-breaking
|
||||
change** — existing identity files still work (they store the raw key bytes),
|
||||
only the over-the-wire representation changes.
|
||||
|
||||
### 2b. Signaling crypto: add nacl/box
|
||||
|
||||
YAW/2 seals signaling payloads (offer/answer/candidates) with libsodium's
|
||||
`crypto_box` — XSalsa20-Poly1305. In Go this is `golang.org/x/crypto/nacl/box`,
|
||||
which is byte-identical to libsodium.
|
||||
|
||||
Add to `internal/crypto/crypto.go`:
|
||||
|
||||
```go
|
||||
import "golang.org/x/crypto/nacl/box"
|
||||
|
||||
// SignalingBox seals a plaintext payload for a recipient.
|
||||
// Returns base64(nonce(24) || ciphertext) as the YAW/2 spec requires.
|
||||
func SignalingBox(plaintext []byte, recipientPub, senderPriv *[32]byte) string {
|
||||
var nonce [24]byte
|
||||
rand.Read(nonce[:])
|
||||
ct := box.Seal(nonce[:], plaintext, &nonce, recipientPub, senderPriv)
|
||||
return base64.StdEncoding.EncodeToString(ct)
|
||||
}
|
||||
|
||||
// SignalingOpen opens a sealed box from a sender.
|
||||
func SignalingOpen(b64box string, senderPub, recipientPriv *[32]byte) ([]byte, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(b64box)
|
||||
if err != nil || len(raw) < 24 {
|
||||
return nil, errors.New("invalid box")
|
||||
}
|
||||
var nonce [24]byte
|
||||
copy(nonce[:], raw[:24])
|
||||
out, ok := box.Open(nil, raw[24:], &nonce, senderPub, recipientPriv)
|
||||
if !ok {
|
||||
return nil, errors.New("box open failed")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 2c. X25519 keys must be derived from the Ed25519 identity
|
||||
|
||||
YAW/2 requires that signaling boxes are sealed using X25519 keys derived from
|
||||
the Ed25519 identity — not independently generated. This is the libsodium
|
||||
`crypto_sign_ed25519_pk_to_curve25519` / `crypto_sign_ed25519_sk_to_curve25519`
|
||||
conversion.
|
||||
|
||||
Add to `Identity`:
|
||||
|
||||
```go
|
||||
import "filippo.io/edwards25519"
|
||||
|
||||
// CurvePublicKey returns the X25519 public key derived from this identity.
|
||||
// Used for sealing signaling boxes (YAW/2 §3).
|
||||
func (id *Identity) CurvePublicKey() *[32]byte {
|
||||
edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey)
|
||||
mont := edPoint.BytesMontgomery()
|
||||
var out [32]byte
|
||||
copy(out[:], mont)
|
||||
return &out
|
||||
}
|
||||
|
||||
// CurvePrivateKey returns the X25519 private key derived from this identity.
|
||||
func (id *Identity) CurvePrivateKey() *[32]byte {
|
||||
// Ed25519 private key is 64 bytes: scalar(32) || pubkey(32)
|
||||
// The X25519 scalar is the clamped SHA-512 first half.
|
||||
h := sha512.Sum512(id.privateKey[:32])
|
||||
h[0] &= 248
|
||||
h[31] &= 127
|
||||
h[31] |= 64
|
||||
var out [32]byte
|
||||
copy(out[:], h[:32])
|
||||
return &out
|
||||
}
|
||||
```
|
||||
|
||||
Add `filippo.io/edwards25519` to `go.mod` (it's a Go standard library
|
||||
dependency already pulled in transitively by `golang.org/x/crypto` — just
|
||||
import it directly).
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — `internal/proto`: wire format updates
|
||||
|
||||
### 3a. PeerID is now hex
|
||||
|
||||
```go
|
||||
// PeerID is lowercase hex of the 32-byte Ed25519 public key (64 chars).
|
||||
type PeerID string
|
||||
|
||||
func (p PeerID) Short() string {
|
||||
// YAW/2 short id: first 16 hex chars, grouped in 4s: "a1b2 c3d4 e5f6 0718"
|
||||
s := string(p)
|
||||
if len(s) < 16 {
|
||||
return s
|
||||
}
|
||||
return s[0:4] + " " + s[4:8] + " " + s[8:12] + " " + s[12:16]
|
||||
}
|
||||
```
|
||||
|
||||
### 3b. Every application message needs a `mid`
|
||||
|
||||
YAW/2 requires a `mid` (random 16-byte hex) on every DataChannel message for
|
||||
deduplication in the full mesh (§8).
|
||||
|
||||
```go
|
||||
type ChatMessage struct {
|
||||
Mid string `json:"mid"` // add this — random 16-byte hex
|
||||
ID string `json:"id"` // can keep for internal use
|
||||
From PeerID `json:"from"`
|
||||
// ... rest unchanged
|
||||
}
|
||||
```
|
||||
|
||||
Generate `mid` the same way as message IDs: `hex.EncodeToString(randomBytes(16))`.
|
||||
|
||||
### 3c. Signaling payload types (new)
|
||||
|
||||
Add a new group of types for the signaling layer — these go *inside* the sealed
|
||||
boxes exchanged over the WebSocket anchor connection:
|
||||
|
||||
```go
|
||||
// SignalingKind identifies the kind of sealed signaling payload.
|
||||
type SignalingKind string
|
||||
|
||||
const (
|
||||
SigOffer SignalingKind = "offer"
|
||||
SigAnswer SignalingKind = "answer"
|
||||
SigCandidate SignalingKind = "candidate"
|
||||
SigBye SignalingKind = "bye"
|
||||
)
|
||||
|
||||
// SignalingPayload is the JSON plaintext sealed inside a crypto_box.
|
||||
type SignalingPayload struct {
|
||||
Kind SignalingKind `json:"kind"`
|
||||
SDP string `json:"sdp,omitempty"` // offer / answer
|
||||
Cand string `json:"cand,omitempty"` // trickle ICE candidate line
|
||||
Mid string `json:"mid,omitempty"` // media stream id for candidate
|
||||
MLine int `json:"mline,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### 3d. Anchor WebSocket wire types (new)
|
||||
|
||||
The WebSocket connection to the anchor speaks its own JSON protocol (§5).
|
||||
Add these alongside the existing IPC types:
|
||||
|
||||
```go
|
||||
// AnchorMsgType identifies anchor WebSocket messages.
|
||||
type AnchorMsgType string
|
||||
|
||||
const (
|
||||
AnchorChallenge AnchorMsgType = "challenge"
|
||||
AnchorJoin AnchorMsgType = "join"
|
||||
AnchorJoined AnchorMsgType = "joined"
|
||||
AnchorPeerJoin AnchorMsgType = "peer-join"
|
||||
AnchorPeerLeave AnchorMsgType = "peer-leave"
|
||||
AnchorTo AnchorMsgType = "to"
|
||||
AnchorFrom AnchorMsgType = "from"
|
||||
AnchorNoPeer AnchorMsgType = "no-peer"
|
||||
)
|
||||
|
||||
// AnchorMessage covers all WebSocket frames to/from the anchor.
|
||||
type AnchorMessage struct {
|
||||
Type AnchorMsgType `json:"type"`
|
||||
Nonce string `json:"nonce,omitempty"` // challenge nonce, hex
|
||||
ID string `json:"id,omitempty"` // peer hex id
|
||||
Net string `json:"net,omitempty"` // hashed network name
|
||||
Sig string `json:"sig,omitempty"` // ed25519 sig, hex
|
||||
Peers []string `json:"peers,omitempty"` // joined response
|
||||
To string `json:"to,omitempty"` // sealed relay target
|
||||
From string `json:"from,omitempty"` // sealed relay sender
|
||||
Box string `json:"box,omitempty"` // base64 crypto_box
|
||||
}
|
||||
```
|
||||
|
||||
### 3e. DataChannel `hello` type (new)
|
||||
|
||||
The first message on every opened DataChannel is a mandatory identity
|
||||
confirmation (§6). Add:
|
||||
|
||||
```go
|
||||
// HelloMessage is the first message sent on the "yaw" DataChannel.
|
||||
// The sig binds this identity to the specific DTLS session.
|
||||
type HelloMessage struct {
|
||||
Type string `json:"type"` // always "hello"
|
||||
ID string `json:"id"` // hex pubkey
|
||||
Nick string `json:"nick"` // alias
|
||||
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
||||
Sig string `json:"sig"` // hex ed25519 sig over bind string (see below)
|
||||
}
|
||||
|
||||
// HelloBindString returns the bytes the hello signature covers:
|
||||
// "yaw/2 bind" || localDTLSFP(32 bytes) || remoteDTLSFP(32 bytes)
|
||||
func HelloBindString(localFP, remoteFP []byte) []byte {
|
||||
buf := []byte("yaw/2 bind")
|
||||
buf = append(buf, localFP...)
|
||||
buf = append(buf, remoteFP...)
|
||||
return buf
|
||||
}
|
||||
```
|
||||
|
||||
### 3f. File transfer: dedicated channel, no chunk type in main channel
|
||||
|
||||
Remove `FileChunk` from `PeerMessage` — chunks go over a separate binary
|
||||
DataChannel labeled `f:<xid>`. Keep `FileOffer`, `FileResponse`, and add
|
||||
`FileDone`:
|
||||
|
||||
```go
|
||||
type FileDone struct {
|
||||
Mid string `json:"mid"`
|
||||
Xid string `json:"xid"` // transfer id
|
||||
SHA256 string `json:"sha256"` // hex, for receiver to verify
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — `internal/mesh/peer.go`: replace with pion/webrtc
|
||||
|
||||
This is the largest single change. The current file (raw TCP dial/accept,
|
||||
manual ECDH, encrypt/decrypt loop) is replaced entirely.
|
||||
|
||||
The new shape:
|
||||
|
||||
```go
|
||||
// ConnectToPeer initiates a WebRTC session with a peer.
|
||||
// Called when we are the offerer (our id < their id, lexicographically).
|
||||
func ConnectToPeer(peerID proto.PeerID, m *Mesh, anchor Anchor) error {
|
||||
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.fnlr.se:3478"}}},
|
||||
})
|
||||
// create "yaw" DataChannel
|
||||
dc, _ := pc.CreateDataChannel("yaw", &webrtc.DataChannelInit{Ordered: ptr(true)})
|
||||
// wire up ICE candidate trickle → seal → send via anchor
|
||||
pc.OnICECandidate(func(c *webrtc.ICECandidate) { /* seal and send */ })
|
||||
// create offer, set local description, seal it, send via anchor
|
||||
offer, _ := pc.CreateOffer(nil)
|
||||
pc.SetLocalDescription(offer)
|
||||
// seal offer → anchor.SendTo(peerID, sealedOffer)
|
||||
// ... dc.OnOpen → sendHello, dc.OnMessage → handleMessage
|
||||
}
|
||||
|
||||
// HandleAnswer processes a sealed answer received from the anchor.
|
||||
func HandleAnswer(sealed string, fromID proto.PeerID, pc *webrtc.PeerConnection, ...) error {
|
||||
// open the nacl box, unmarshal SignalingPayload, set remote description
|
||||
}
|
||||
```
|
||||
|
||||
Key things pion handles for you that you were going to write manually:
|
||||
- All ICE candidate gathering (host + server-reflexive via STUN)
|
||||
- The DTLS handshake and key material
|
||||
- SCTP framing over UDP
|
||||
- DataChannel reliable/ordered delivery
|
||||
|
||||
Key things you still write:
|
||||
- Sealing/opening signaling payloads with `nacl/box` before sending to anchor
|
||||
- The `hello` confirmation on DataChannel open (mandatory per §6)
|
||||
- Verifying the hello signature against DTLS fingerprints
|
||||
- Deciding who offers (smaller id offers — one `strings.Compare` call)
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — `internal/nat`: delete it
|
||||
|
||||
Remove the package entirely. ICE does what this package was going to do, and
|
||||
does it correctly for both host-to-host and NAT-traversal cases. The only
|
||||
"NAT" configuration you provide is the STUN server URL passed to
|
||||
`webrtc.NewPeerConnection`.
|
||||
|
||||
Update `cmd/daemon/main.go` to remove the `nat.Run(...)` goroutine and the
|
||||
`-relay` flag.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — `cmd/relay` → `cmd/anchor`
|
||||
|
||||
Rename the binary and replace its internals. The anchor is a WebSocket server,
|
||||
not a TCP server. It never reads the content of `box` fields.
|
||||
|
||||
Rough structure:
|
||||
|
||||
```go
|
||||
// cmd/anchor/main.go
|
||||
// Uses golang.org/x/net/websocket or nhooyr.io/websocket
|
||||
|
||||
// Per-connection state
|
||||
type client struct {
|
||||
id string // hex peer id, set after join
|
||||
net string // hashed network name
|
||||
ch chan []byte // outbound message queue
|
||||
}
|
||||
|
||||
// On "join": verify ed25519 sig over (nonce || net), register (net, id) → client
|
||||
// On "to": look up (net, to) → forward {type:"from", from:senderID, box:...}
|
||||
// On disconnect: broadcast {type:"peer-leave", id:...} to net members
|
||||
```
|
||||
|
||||
The anchor also runs (or points to) a STUN server. The simplest approach is
|
||||
to run `coturn` on your Hetzner VPS in STUN-only mode alongside the anchor
|
||||
binary, or use a public STUN server during development (`stun:stun.l.google.com:19302`).
|
||||
|
||||
The network name is never stored in plaintext:
|
||||
|
||||
```go
|
||||
func hashNetName(name string) string {
|
||||
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — `internal/ipc`: minor additions
|
||||
|
||||
The IPC layer mostly survives. Add two new commands:
|
||||
|
||||
```go
|
||||
// Connect via anchor (replaces direct TCP connect)
|
||||
CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext)
|
||||
CmdLeaveNetwork IpcMsgType = "leave_network"
|
||||
|
||||
// New event when a peer's DataChannel opens and hello is verified
|
||||
EvtSessionReady IpcMsgType = "session_ready" // fields: peer_id, nick
|
||||
```
|
||||
|
||||
Remove `CmdConnect` (direct TCP dial) — there's no direct dialing in YAW/2,
|
||||
only joining a named network via the anchor.
|
||||
|
||||
---
|
||||
|
||||
## Order to implement
|
||||
|
||||
Do these in order — each step produces something testable before moving to the next.
|
||||
|
||||
1. **Crypto changes** (Step 2) — unit-testable, no network involved. Write a
|
||||
test that round-trips a `SignalingBox` seal/open and confirms the hex id format.
|
||||
|
||||
2. **Proto additions** (Step 3) — add types, confirm it compiles.
|
||||
|
||||
3. **Anchor server** (Step 6) — build this first so you have something to connect
|
||||
to. Test with `websocat` or a browser `WebSocket` console.
|
||||
|
||||
4. **WebRTC peer connection** (Step 4) — start with two daemons on the same LAN,
|
||||
anchor running locally. Confirm ICE succeeds and the `hello` bind check passes.
|
||||
|
||||
5. **Delete nat, update ipc, update daemon main** (Steps 5 + 7) — cleanup after
|
||||
the above works.
|
||||
|
||||
6. **File transfer** — once chat works, add the `f:<xid>` DataChannel flow.
|
||||
|
||||
---
|
||||
|
||||
## What interoperability actually means in practice
|
||||
|
||||
Once this is done, your Go daemon and your friend's web/Tauri/Python client can
|
||||
be in the same named network. They'll connect to the same anchor WebSocket,
|
||||
exchange sealed offers/answers, and open DataChannels directly to each other.
|
||||
The `hello` message format, the signaling box format, and the DataChannel
|
||||
message types are the shared surface — as long as those match §5–§9 of the
|
||||
YAW/2 spec, the implementations are interoperable regardless of language.
|
||||
|
||||
The reference STUN and signaling endpoints in the spec are `stun.fnlr.se:3478`
|
||||
and `wss://fnlr.se/...` (path TBD) — coordinate with your friend on the final
|
||||
WebSocket path before wiring it in.
|
||||
91
README.md
91
README.md
@@ -9,18 +9,18 @@ friend-to-friend encrypted mesh networking with chat and file sharing. Written i
|
||||
waste-go/
|
||||
├── cmd/
|
||||
│ ├── daemon/ The peer process — run one on each friend's machine
|
||||
│ └── relay/ Bootstrap/relay server — run this on your Hetzner VPS
|
||||
│ └── anchor/ WebSocket signaling server — run this on your Hetzner VPS
|
||||
└── internal/
|
||||
├── proto/ All wire types (shared by daemon and relay)
|
||||
├── crypto/ Ed25519 identity, X25519 ECDH, ChaCha20-Poly1305
|
||||
├── mesh/ Connected peer state + per-connection handler
|
||||
├── ipc/ Local JSON API (UI talks to daemon here, port 17337)
|
||||
└── nat/ Relay client (hole-punching lives here later)
|
||||
├── proto/ All wire types (shared by daemon and anchor)
|
||||
├── crypto/ Ed25519 identity, nacl/box signaling, ChaCha20-Poly1305
|
||||
<EFBFBD><EFBFBD><EFBFBD>── mesh/ Connected peer state + DataChannel helpers
|
||||
├── anchor/ Anchor client — WebRTC signaling via the anchor server
|
||||
└── ipc/ Local JSON API (UI talks to daemon here, port 17337)
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.22+ → https://go.dev/dl/
|
||||
- Go 1.24+ → https://go.dev/dl/
|
||||
- VS Code with the Go extension (`golang.go`)
|
||||
|
||||
On first open VS Code will prompt you to install `gopls`, `dlv`, and `goimports` — accept all of them.
|
||||
@@ -34,38 +34,39 @@ go mod tidy
|
||||
# Build everything (confirms it compiles)
|
||||
go build ./...
|
||||
|
||||
# Terminal 1 — relay (optional for LAN testing, required across internet)
|
||||
go run ./cmd/relay -bind 127.0.0.1:17339
|
||||
# Terminal 1 — anchor (required for peers to find each other)
|
||||
go run ./cmd/anchor -bind 127.0.0.1:17339
|
||||
|
||||
# Terminal 2 — peer A
|
||||
go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -peer-port 17338 -ipc-port 17337
|
||||
go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -ipc-port 17337 -anchor ws://127.0.0.1:17339/ws
|
||||
|
||||
# Terminal 3 — peer B
|
||||
go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -peer-port 17340 -ipc-port 17341
|
||||
go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -ipc-port 17341 -anchor ws://127.0.0.1:17339/ws
|
||||
```
|
||||
|
||||
Then connect B → A and send a message (netcat works fine as a quick test):
|
||||
Both peers join the same named network via IPC:
|
||||
|
||||
```bash
|
||||
# Tell peer B to connect to peer A
|
||||
echo '{"type":"connect","addr":"127.0.0.1:17338"}' | nc 127.0.0.1 17341
|
||||
# Join peer A to a network called "friends"
|
||||
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17337
|
||||
|
||||
# In another terminal — subscribe to peer A's events, then send a message from B
|
||||
# Join peer B to the same network
|
||||
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17341
|
||||
|
||||
# Subscribe to peer A's events (in a separate terminal)
|
||||
nc 127.0.0.1 17337 &
|
||||
|
||||
# Send a message from B
|
||||
echo '{"type":"send_message","room":"general","body":"hello from bob"}' | nc 127.0.0.1 17341
|
||||
```
|
||||
|
||||
**On Windows** — use PowerShell's built-in TCP client instead of `nc`:
|
||||
|
||||
```powershell
|
||||
# Open a connection to peer B's IPC port and keep it open
|
||||
$c = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17341)
|
||||
$w = [System.IO.StreamWriter]::new($c.GetStream()); $w.AutoFlush = $true
|
||||
|
||||
# Tell peer B to connect to peer A
|
||||
$w.WriteLine('{"type":"connect","addr":"127.0.0.1:17338"}')
|
||||
|
||||
# Send a chat message from B
|
||||
$w.WriteLine('{"type":"join_network","network_name":"friends"}')
|
||||
$w.WriteLine('{"type":"send_message","room":"general","body":"hello from bob"}')
|
||||
|
||||
# In a separate terminal — subscribe to peer A's events
|
||||
@@ -74,20 +75,18 @@ $reader = [System.IO.StreamReader]::new($r.GetStream())
|
||||
while ($true) { $reader.ReadLine() }
|
||||
```
|
||||
|
||||
Keep `$c` / `$w` in scope for the session; closing them disconnects the peer.
|
||||
|
||||
## Deploying the relay on your Hetzner VPS
|
||||
## Deploying the anchor on your Hetzner VPS
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 go build -o bin/waste-relay ./cmd/relay
|
||||
scp bin/waste-relay user@your-vps:~/
|
||||
GOOS=linux GOARCH=amd64 go build -o bin/waste-anchor ./cmd/anchor
|
||||
scp bin/waste-anchor user@your-vps:~/
|
||||
|
||||
# On the VPS
|
||||
./waste-relay -bind 0.0.0.0:17339
|
||||
# On the VPS (also run coturn in STUN-only mode on port 3478)
|
||||
./waste-anchor -bind 0.0.0.0:17339
|
||||
```
|
||||
|
||||
Then start daemons with `-relay your-vps-ip:17339` and they'll register and
|
||||
be able to find each other across NAT.
|
||||
Then start daemons with `-anchor ws://your-vps-ip:17339/ws` and they'll connect via WebRTC
|
||||
with ICE (STUN-assisted hole punching) through the anchor for signaling.
|
||||
|
||||
## IPC protocol (plain JSON over TCP)
|
||||
|
||||
@@ -95,7 +94,8 @@ Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
|
||||
|
||||
**Commands you send:**
|
||||
```jsonc
|
||||
{"type":"connect","addr":"1.2.3.4:17338"}
|
||||
{"type":"join_network","network_name":"friends"}
|
||||
{"type":"leave_network"}
|
||||
{"type":"send_message","room":"general","body":"hi"}
|
||||
{"type":"get_state"}
|
||||
```
|
||||
@@ -104,8 +104,9 @@ Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
|
||||
```jsonc
|
||||
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...]}
|
||||
{"type":"peer_connected","peer":{...}}
|
||||
{"type":"message_received","message":{"from":"...","body":"hi","room":"general"}}
|
||||
{"type":"peer_disconnected","peer_id":"..."}
|
||||
{"type":"session_ready","peer_id":"<hex>","nick":"alice"}
|
||||
{"type":"message_received","message":{"from":"<hex>","body":"hi","room":"general"}}
|
||||
{"type":"peer_disconnected","peer_id":"<hex>"}
|
||||
```
|
||||
|
||||
## Crypto choices
|
||||
@@ -113,17 +114,25 @@ Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
|
||||
| Purpose | Algorithm | Notes |
|
||||
|---|---|---|
|
||||
| Identity | Ed25519 | Fast, small keys, standard |
|
||||
| Key exchange | X25519 ECDH | Per-session ephemeral keys |
|
||||
| Symmetric | ChaCha20-Poly1305 | No AES-NI needed, authenticated |
|
||||
| Hashing | SHA-256 | File integrity |
|
||||
| Peer ID | Hex-encoded Ed25519 pubkey | 64 lowercase hex chars (YAW/2 §2) |
|
||||
| Signaling encryption | XSalsa20-Poly1305 (`nacl/box`) | X25519 keys derived from Ed25519 identity (YAW/2 §3) |
|
||||
| Transport | WebRTC DataChannels (DTLS+SCTP) | pion/webrtc — ICE, hole punching included |
|
||||
| Hashing | SHA-256 | File integrity, network name hashing |
|
||||
|
||||
Replaces WASTE's original Blowfish/PCBC (broken cipher mode) + RSA.
|
||||
|
||||
> Peer IDs are 64-char lowercase hex (Ed25519 public key). Existing `identity.json` files
|
||||
> on disk are unaffected — only the over-the-wire representation changed from base64url.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] UDP hole-punching (STUN) in `internal/nat`
|
||||
- [ ] Invite file format (`.waste-invite`) — share a keypair + address hint
|
||||
- [ ] Peer gossip → auto-connect to friends-of-friends
|
||||
- [ ] File transfer
|
||||
- [ ] Message persistence (SQLite via `modernc.org/sqlite`)
|
||||
- [ ] Tauri or simple web UI consuming the IPC port
|
||||
- [x] **Crypto layer** — hex peer IDs, `nacl/box` signaling, Ed25519→X25519 key derivation
|
||||
- [x] **Proto additions** — `mid` field, signaling types, anchor wire types, `hello` message, `FileDone`
|
||||
- [x] **Anchor server** (`cmd/anchor`) — WebSocket signaling server (replaces TCP relay)
|
||||
- [x] **WebRTC peer connections** — pion/webrtc DataChannels replace raw TCP in `internal/mesh`
|
||||
- [x] **Anchor client** (`internal/anchor`) — ICE offer/answer/candidate lifecycle, `nacl/box` sealing
|
||||
- [x] **IPC updates** — `join_network`/`leave_network` replace `connect`; `session_ready` event added
|
||||
- [ ] **File transfer** — chunked binary DataChannel (`f:<xid>`)
|
||||
- [ ] **Message persistence** — SQLite via `modernc.org/sqlite`
|
||||
- [ ] **TUI** — Bubble Tea client consuming the IPC port
|
||||
- [ ] **Native UI** — web frontend with native packaging (Tauri-style)
|
||||
|
||||
246
cmd/anchor/main.go
Normal file
246
cmd/anchor/main.go
Normal file
@@ -0,0 +1,246 @@
|
||||
// waste-anchor: WebSocket signaling server for YAW/2.
|
||||
// Deploy on your Hetzner VPS alongside a STUN server (coturn in STUN-only mode).
|
||||
// The anchor never reads the content of "box" fields — it only routes sealed blobs.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
"nhooyr.io/websocket/wsjson"
|
||||
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
func main() {
|
||||
bind := flag.String("bind", "0.0.0.0:17339", "address to listen on")
|
||||
flag.Parse()
|
||||
|
||||
a := newAnchor()
|
||||
http.HandleFunc("/ws", a.handleWS)
|
||||
log.Printf("anchor: listening on %s", *bind)
|
||||
if err := http.ListenAndServe(*bind, nil); err != nil {
|
||||
log.Fatalf("anchor: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Anchor ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type client struct {
|
||||
id string // hex peer id, set after join
|
||||
net string // hashed network name, set after join
|
||||
send chan proto.AnchorMessage
|
||||
conn *websocket.Conn
|
||||
}
|
||||
|
||||
type anchor struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]*client // keyed by hex peer id
|
||||
}
|
||||
|
||||
func newAnchor() *anchor {
|
||||
return &anchor{clients: make(map[string]*client)}
|
||||
}
|
||||
|
||||
func (a *anchor) register(c *client) {
|
||||
a.mu.Lock()
|
||||
a.clients[c.id] = c
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *anchor) unregister(c *client) {
|
||||
if c.id == "" {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
delete(a.clients, c.id)
|
||||
a.mu.Unlock()
|
||||
|
||||
// Notify everyone in the same network.
|
||||
leave := proto.AnchorMessage{Type: proto.AnchorPeerLeave, ID: c.id}
|
||||
a.mu.RLock()
|
||||
for _, peer := range a.clients {
|
||||
if peer.net == c.net {
|
||||
select {
|
||||
case peer.send <- leave:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
log.Printf("anchor: peer left: %s", c.id[:min(8, len(c.id))])
|
||||
}
|
||||
|
||||
// networkPeerIDs returns the hex ids of all peers in the same network as netHash.
|
||||
func (a *anchor) networkPeerIDs(netHash, excludeID string) []string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
var ids []string
|
||||
for id, c := range a.clients {
|
||||
if c.net == netHash && id != excludeID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (a *anchor) forwardTo(toID string, msg proto.AnchorMessage) bool {
|
||||
a.mu.RLock()
|
||||
c, ok := a.clients[toID]
|
||||
a.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case c.send <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket handler ─────────────────────────────────────────────────────────
|
||||
|
||||
func (a *anchor) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
log.Printf("anchor: ws accept: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "bye")
|
||||
|
||||
c := &client{
|
||||
send: make(chan proto.AnchorMessage, 64),
|
||||
conn: conn,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
defer cancel()
|
||||
|
||||
// Send a challenge nonce immediately.
|
||||
nonce := make([]byte, 16)
|
||||
rand.Read(nonce)
|
||||
nonceHex := hex.EncodeToString(nonce)
|
||||
if err := wsjson.Write(ctx, conn, proto.AnchorMessage{
|
||||
Type: proto.AnchorChallenge,
|
||||
Nonce: nonceHex,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Writer goroutine.
|
||||
go func() {
|
||||
for msg := range c.send {
|
||||
if err := wsjson.Write(ctx, conn, msg); err != nil {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Read loop.
|
||||
for {
|
||||
var msg proto.AnchorMessage
|
||||
if err := wsjson.Read(ctx, conn, &msg); err != nil {
|
||||
break
|
||||
}
|
||||
switch msg.Type {
|
||||
|
||||
case proto.AnchorJoin:
|
||||
if msg.ID == "" || msg.Net == "" || msg.Sig == "" {
|
||||
log.Printf("anchor: join missing fields from %s", r.RemoteAddr)
|
||||
continue
|
||||
}
|
||||
pubBytes, err := hex.DecodeString(msg.ID)
|
||||
if err != nil || len(pubBytes) != ed25519.PublicKeySize {
|
||||
log.Printf("anchor: join: bad peer id from %s", r.RemoteAddr)
|
||||
continue
|
||||
}
|
||||
sigBytes, err := hex.DecodeString(msg.Sig)
|
||||
if err != nil {
|
||||
log.Printf("anchor: join: bad sig from %s", r.RemoteAddr)
|
||||
continue
|
||||
}
|
||||
// Sig covers nonce || net (both as raw bytes decoded from hex/plaintext).
|
||||
netBytes, _ := hex.DecodeString(msg.Net)
|
||||
signed := append(nonce, netBytes...)
|
||||
if !ed25519.Verify(ed25519.PublicKey(pubBytes), signed, sigBytes) {
|
||||
log.Printf("anchor: join: sig verification failed for %s", msg.ID[:min(8, len(msg.ID))])
|
||||
continue
|
||||
}
|
||||
|
||||
c.id = msg.ID
|
||||
c.net = msg.Net
|
||||
a.register(c)
|
||||
|
||||
peers := a.networkPeerIDs(msg.Net, c.id)
|
||||
if err := wsjson.Write(ctx, conn, proto.AnchorMessage{
|
||||
Type: proto.AnchorJoined,
|
||||
Peers: peers,
|
||||
}); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Notify existing peers that someone new joined.
|
||||
join := proto.AnchorMessage{Type: proto.AnchorPeerJoin, ID: c.id}
|
||||
for _, pid := range peers {
|
||||
a.forwardTo(pid, join)
|
||||
}
|
||||
log.Printf("anchor: peer joined: %s net=%s peers=%d", c.id[:min(8, len(c.id))], msg.Net[:8], len(peers))
|
||||
|
||||
case proto.AnchorTo:
|
||||
if c.id == "" {
|
||||
continue // not joined yet
|
||||
}
|
||||
if msg.To == "" || msg.Box == "" {
|
||||
continue
|
||||
}
|
||||
fwd := proto.AnchorMessage{
|
||||
Type: proto.AnchorFrom,
|
||||
From: c.id,
|
||||
Box: msg.Box,
|
||||
}
|
||||
if !a.forwardTo(msg.To, fwd) {
|
||||
select {
|
||||
case c.send <- proto.AnchorMessage{Type: proto.AnchorNoPeer, ID: msg.To}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
log.Printf("anchor: unknown message type %q from %s", msg.Type, r.RemoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
a.unregister(c)
|
||||
close(c.send)
|
||||
}
|
||||
|
||||
// hashNetName returns the SHA-256 hash of a plaintext network name.
|
||||
// The anchor stores only hashed names — it never sees the plaintext.
|
||||
func hashNetName(name string) string {
|
||||
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// Compile-time check: hashNetName is used by daemons, kept here for reference.
|
||||
var _ = hashNetName
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Ensure wsjson import is used (avoids accidental drop).
|
||||
var _ = time.Now
|
||||
@@ -3,63 +3,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"github.com/waste-go/internal/anchor"
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/ipc"
|
||||
"github.com/waste-go/internal/mesh"
|
||||
"github.com/waste-go/internal/nat"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dataDir := flag.String("data-dir", "~/.waste", "path to identity/config directory")
|
||||
alias := flag.String("alias", "anon", "display name shown to peers (advisory only)")
|
||||
peerPort := flag.Int("peer-port", 17338, "port to accept incoming peer connections")
|
||||
ipcPort := flag.Int("ipc-port", 17337, "port for local IPC (UI connects here)")
|
||||
relay := flag.String("relay", "", "optional relay server address (host:port)")
|
||||
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
|
||||
flag.Parse()
|
||||
|
||||
// Load or generate identity keypair
|
||||
id, err := crypto.LoadOrCreate(expandHome(*dataDir), *alias)
|
||||
if err != nil {
|
||||
log.Fatalf("identity: %v", err)
|
||||
}
|
||||
log.Printf("daemon: local peer id: %s alias: %s", id.PeerID().Short(), id.Alias)
|
||||
|
||||
// Shared mesh state
|
||||
m := mesh.New(id)
|
||||
|
||||
// Peer listener (inbound connections from other nodes)
|
||||
go func() {
|
||||
addr := fmt.Sprintf("0.0.0.0:%d", *peerPort)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Fatalf("peer listener: %v", err)
|
||||
// joinFn is passed to the IPC layer; it's called when the UI sends join_network.
|
||||
joinFn := func(ctx context.Context, networkName string) {
|
||||
if *anchorURL == "" {
|
||||
log.Printf("daemon: join_network: no -anchor flag set")
|
||||
m.Emit(proto.IpcMessage{Type: proto.EvtError, ErrorMessage: "no anchor configured — start daemon with -anchor <url>"})
|
||||
return
|
||||
}
|
||||
log.Printf("daemon: listening for peers on %s", addr)
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Printf("peer accept error: %v", err)
|
||||
continue
|
||||
log.Printf("daemon: joining network %q via %s", networkName, *anchorURL)
|
||||
anchor.Run(ctx, *anchorURL, networkName, id, m)
|
||||
log.Printf("daemon: left network %q", networkName)
|
||||
}
|
||||
go mesh.HandleConn(conn, m, false)
|
||||
}
|
||||
}()
|
||||
|
||||
// NAT traversal / relay client
|
||||
go func() {
|
||||
if err := nat.Run(m, *relay); err != nil {
|
||||
log.Printf("nat: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// IPC server — blocks until error
|
||||
if err := ipc.Run(m, *ipcPort); err != nil {
|
||||
if err := ipc.Run(m, *ipcPort, joinFn); err != nil {
|
||||
log.Fatalf("ipc: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -72,3 +55,4 @@ func expandHome(path string) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
// waste-relay: bootstrap and relay server.
|
||||
// Deploy this on your Hetzner VPS. Peers register here and can ask it
|
||||
// to forward encrypted envelopes to other peers.
|
||||
// The relay never sees plaintext — it only shuffles opaque blobs.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
func main() {
|
||||
bind := flag.String("bind", "0.0.0.0:17339", "address to listen on")
|
||||
flag.Parse()
|
||||
|
||||
ln, err := net.Listen("tcp", *bind)
|
||||
if err != nil {
|
||||
log.Fatalf("relay: listen on %s: %v", *bind, err)
|
||||
}
|
||||
log.Printf("relay: listening on %s", *bind)
|
||||
|
||||
r := newRegistry()
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Printf("relay: accept error: %v", err)
|
||||
continue
|
||||
}
|
||||
go r.handleClient(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type registeredPeer struct {
|
||||
gossip proto.GossipEntry
|
||||
send chan proto.RelayMessage // write to this to forward a message
|
||||
}
|
||||
|
||||
type registry struct {
|
||||
mu sync.RWMutex
|
||||
peers map[proto.PeerID]*registeredPeer
|
||||
}
|
||||
|
||||
func newRegistry() *registry {
|
||||
return ®istry{peers: make(map[proto.PeerID]*registeredPeer)}
|
||||
}
|
||||
|
||||
func (r *registry) register(id proto.PeerID, entry proto.GossipEntry, send chan proto.RelayMessage) {
|
||||
r.mu.Lock()
|
||||
r.peers[id] = ®isteredPeer{gossip: entry, send: send}
|
||||
r.mu.Unlock()
|
||||
log.Printf("relay: registered %s (%s)", id.Short(), entry.Peer.Alias)
|
||||
}
|
||||
|
||||
func (r *registry) unregister(id proto.PeerID) {
|
||||
r.mu.Lock()
|
||||
delete(r.peers, id)
|
||||
r.mu.Unlock()
|
||||
log.Printf("relay: unregistered %s", id.Short())
|
||||
}
|
||||
|
||||
func (r *registry) listPeers() []proto.GossipEntry {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]proto.GossipEntry, 0, len(r.peers))
|
||||
for _, p := range r.peers {
|
||||
out = append(out, p.gossip)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *registry) forward(to proto.PeerID, msg proto.RelayMessage) bool {
|
||||
r.mu.RLock()
|
||||
p, ok := r.peers[to]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case p.send <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Client handler ────────────────────────────────────────────────────────────
|
||||
|
||||
func (r *registry) handleClient(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
addr := conn.RemoteAddr().String()
|
||||
|
||||
sendCh := make(chan proto.RelayMessage, 64)
|
||||
var myID proto.PeerID
|
||||
|
||||
// Writer goroutine
|
||||
go func() {
|
||||
enc := json.NewEncoder(conn)
|
||||
for msg := range sendCh {
|
||||
if err := enc.Encode(msg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
send := func(msg proto.RelayMessage) {
|
||||
select {
|
||||
case sendCh <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
var msg proto.RelayMessage
|
||||
if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
|
||||
log.Printf("relay: bad message from %s: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
|
||||
case proto.RelayRegister:
|
||||
if msg.Peer == nil {
|
||||
send(proto.RelayMessage{Type: proto.RelayError, Message: "register: peer required"})
|
||||
continue
|
||||
}
|
||||
// TODO: verify msg.Signature against msg.Peer.PublicKey
|
||||
myID = msg.Peer.ID
|
||||
entry := proto.GossipEntry{
|
||||
Peer: *msg.Peer,
|
||||
AddrHint: addr,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
r.register(myID, entry, sendCh)
|
||||
|
||||
case proto.RelayListPeers:
|
||||
send(proto.RelayMessage{
|
||||
Type: proto.RelayPeerList,
|
||||
Peers: r.listPeers(),
|
||||
})
|
||||
|
||||
case proto.RelayForward:
|
||||
if msg.To == nil || msg.Envelope == nil {
|
||||
send(proto.RelayMessage{Type: proto.RelayError, Message: "forward: to and envelope required"})
|
||||
continue
|
||||
}
|
||||
fwd := proto.RelayMessage{
|
||||
Type: proto.RelayForwarded,
|
||||
From: &myID,
|
||||
Envelope: msg.Envelope,
|
||||
}
|
||||
if !r.forward(*msg.To, fwd) {
|
||||
send(proto.RelayMessage{
|
||||
Type: proto.RelayError,
|
||||
Message: "peer not connected to relay",
|
||||
})
|
||||
}
|
||||
|
||||
default:
|
||||
log.Printf("relay: unknown message type %q from %s", msg.Type, addr)
|
||||
}
|
||||
}
|
||||
|
||||
close(sendCh)
|
||||
if myID != "" {
|
||||
r.unregister(myID)
|
||||
}
|
||||
}
|
||||
30
go.mod
30
go.mod
@@ -1,10 +1,36 @@
|
||||
module github.com/waste-go
|
||||
|
||||
go 1.22
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
golang.org/x/crypto v0.24.0
|
||||
nhooyr.io/websocket v1.8.17
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.21.0 // indirect
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pion/datachannel v1.5.8 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
github.com/pion/ice/v2 v2.3.38 // indirect
|
||||
github.com/pion/interceptor v0.1.29 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/mdns v0.0.12 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.14 // indirect
|
||||
github.com/pion/rtp v1.8.7 // indirect
|
||||
github.com/pion/sctp v1.8.19 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.9 // indirect
|
||||
github.com/pion/srtp/v2 v2.0.20 // indirect
|
||||
github.com/pion/stun v0.6.1 // indirect
|
||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||
github.com/pion/webrtc/v3 v3.3.6 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.3 // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
121
go.sum
121
go.sum
@@ -1,6 +1,127 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
|
||||
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
|
||||
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
|
||||
github.com/pion/ice/v2 v2.3.38 h1:DEpt13igPfvkE2+1Q+6e8mP30dtWnQD3CtMIKoRDRmA=
|
||||
github.com/pion/ice/v2 v2.3.38/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
|
||||
github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M=
|
||||
github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
|
||||
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
|
||||
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM=
|
||||
github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/sctp v1.8.19 h1:2CYuw+SQ5vkQ9t0HdOPccsCz1GQMDuVy5PglLgKVBW8=
|
||||
github.com/pion/sctp v1.8.19/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE=
|
||||
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
|
||||
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
|
||||
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
|
||||
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
|
||||
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
|
||||
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
|
||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
||||
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
|
||||
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||
|
||||
348
internal/anchor/client.go
Normal file
348
internal/anchor/client.go
Normal file
@@ -0,0 +1,348 @@
|
||||
// Package anchor implements the YAW/2 anchor client.
|
||||
// It connects to the anchor WebSocket, handles challenge/join, and routes
|
||||
// sealed signaling payloads. It manages PeerConnection lifecycle and delegates
|
||||
// DataChannel handling to internal/mesh.
|
||||
package anchor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"filippo.io/edwards25519"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"nhooyr.io/websocket"
|
||||
"nhooyr.io/websocket/wsjson"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/mesh"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// Run connects to anchorURL, joins networkName, and blocks handling signaling.
|
||||
// Reconnects automatically on disconnect. Cancel ctx to stop.
|
||||
func Run(ctx context.Context, anchorURL, networkName string, id *crypto.Identity, m *mesh.Mesh) {
|
||||
netHash := hashNetName(networkName)
|
||||
for {
|
||||
if err := runOnce(ctx, anchorURL, netHash, id, m); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("anchor: %v — reconnecting in 5s", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity, m *mesh.Mesh) error {
|
||||
conn, _, err := websocket.Dial(ctx, anchorURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "bye")
|
||||
log.Printf("anchor: connected to %s", anchorURL)
|
||||
|
||||
sendCh := make(chan proto.AnchorMessage, 64)
|
||||
go func() {
|
||||
for msg := range sendCh {
|
||||
if err := wsjson.Write(ctx, conn, msg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
pcs = make(map[proto.PeerID]*webrtc.PeerConnection)
|
||||
)
|
||||
|
||||
sender := &sender{id: id, sendCh: sendCh}
|
||||
|
||||
for {
|
||||
var msg proto.AnchorMessage
|
||||
if err := wsjson.Read(ctx, conn, &msg); err != nil {
|
||||
return fmt.Errorf("read: %w", err)
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
|
||||
case proto.AnchorChallenge:
|
||||
nonceBytes, err := hex.DecodeString(msg.Nonce)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad challenge nonce: %w", err)
|
||||
}
|
||||
netBytes, _ := hex.DecodeString(netHash)
|
||||
sig := id.Sign(append(nonceBytes, netBytes...))
|
||||
sendCh <- proto.AnchorMessage{
|
||||
Type: proto.AnchorJoin,
|
||||
ID: string(id.PeerID()),
|
||||
Net: netHash,
|
||||
Sig: sig,
|
||||
}
|
||||
|
||||
case proto.AnchorJoined:
|
||||
log.Printf("anchor: joined network, %d peer(s) present", len(msg.Peers))
|
||||
for _, peerHex := range msg.Peers {
|
||||
pid := proto.PeerID(peerHex)
|
||||
if strings.Compare(string(id.PeerID()), peerHex) > 0 {
|
||||
go func(pid proto.PeerID) {
|
||||
pc, err := offer(pid, id, m, sender)
|
||||
if err != nil {
|
||||
log.Printf("anchor: offer to %s: %v", pid.Short(), err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
pcs[pid] = pc
|
||||
mu.Unlock()
|
||||
}(pid)
|
||||
}
|
||||
}
|
||||
|
||||
case proto.AnchorPeerJoin:
|
||||
pid := proto.PeerID(msg.ID)
|
||||
log.Printf("anchor: peer joined: %s", pid.Short())
|
||||
if strings.Compare(string(id.PeerID()), msg.ID) > 0 {
|
||||
go func(pid proto.PeerID) {
|
||||
pc, err := offer(pid, id, m, sender)
|
||||
if err != nil {
|
||||
log.Printf("anchor: offer to %s: %v", pid.Short(), err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
pcs[pid] = pc
|
||||
mu.Unlock()
|
||||
}(pid)
|
||||
}
|
||||
|
||||
case proto.AnchorPeerLeave:
|
||||
pid := proto.PeerID(msg.ID)
|
||||
mu.Lock()
|
||||
if pc, ok := pcs[pid]; ok {
|
||||
pc.Close()
|
||||
delete(pcs, pid)
|
||||
}
|
||||
mu.Unlock()
|
||||
log.Printf("anchor: peer left: %s", pid.Short())
|
||||
|
||||
case proto.AnchorFrom:
|
||||
fromID := proto.PeerID(msg.From)
|
||||
payload, err := openBox(msg.Box, fromID, id)
|
||||
if err != nil {
|
||||
log.Printf("anchor: open box from %s: %v", fromID.Short(), err)
|
||||
continue
|
||||
}
|
||||
dispatchSignaling(ctx, payload, fromID, id, m, sender, pcs, &mu)
|
||||
|
||||
case proto.AnchorNoPeer:
|
||||
log.Printf("anchor: no such peer: %s", proto.PeerID(msg.ID).Short())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── signaling dispatch ────────────────────────────────────────────────────────
|
||||
|
||||
func dispatchSignaling(
|
||||
ctx context.Context,
|
||||
payload proto.SignalingPayload,
|
||||
fromID proto.PeerID,
|
||||
id *crypto.Identity,
|
||||
m *mesh.Mesh,
|
||||
s *sender,
|
||||
pcs map[proto.PeerID]*webrtc.PeerConnection,
|
||||
mu *sync.RWMutex,
|
||||
) {
|
||||
switch payload.Kind {
|
||||
|
||||
case proto.SigOffer:
|
||||
go func() {
|
||||
pc, err := answer(payload, fromID, id, m, s)
|
||||
if err != nil {
|
||||
log.Printf("anchor: answer to %s: %v", fromID.Short(), err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
pcs[fromID] = pc
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
case proto.SigAnswer:
|
||||
mu.RLock()
|
||||
pc, ok := pcs[fromID]
|
||||
mu.RUnlock()
|
||||
if !ok {
|
||||
log.Printf("anchor: answer from %s but no PeerConnection", fromID.Short())
|
||||
return
|
||||
}
|
||||
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeAnswer,
|
||||
SDP: payload.SDP,
|
||||
}); err != nil {
|
||||
log.Printf("anchor: set remote answer from %s: %v", fromID.Short(), err)
|
||||
}
|
||||
|
||||
case proto.SigCandidate:
|
||||
mu.RLock()
|
||||
pc, ok := pcs[fromID]
|
||||
mu.RUnlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := pc.AddICECandidate(webrtc.ICECandidateInit{
|
||||
Candidate: payload.Cand,
|
||||
SDPMid: strPtr(payload.Mid),
|
||||
SDPMLineIndex: uint16Ptr(uint16(payload.MLine)),
|
||||
}); err != nil {
|
||||
log.Printf("anchor: add candidate from %s: %v", fromID.Short(), err)
|
||||
}
|
||||
|
||||
case proto.SigBye:
|
||||
mu.Lock()
|
||||
if pc, ok := pcs[fromID]; ok {
|
||||
pc.Close()
|
||||
delete(pcs, fromID)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// ── offer / answer helpers ────────────────────────────────────────────────────
|
||||
|
||||
func offer(peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*webrtc.PeerConnection, error) {
|
||||
pc, err := newPC()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dc, err := pc.CreateDataChannel("yaw", &webrtc.DataChannelInit{Ordered: boolPtr(true)})
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
mesh.WireDataChannel(dc, pc, peerID, id, m)
|
||||
mesh.WireCandidateTrickle(pc, peerID, s)
|
||||
|
||||
sdpOffer, err := pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := pc.SetLocalDescription(sdpOffer); err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pc, s.SendTo(peerID, proto.SignalingPayload{Kind: proto.SigOffer, SDP: sdpOffer.SDP})
|
||||
}
|
||||
|
||||
func answer(payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*webrtc.PeerConnection, error) {
|
||||
pc, err := newPC()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
|
||||
if dc.Label() == "yaw" {
|
||||
mesh.WireDataChannel(dc, pc, fromID, id, m)
|
||||
}
|
||||
})
|
||||
mesh.WireCandidateTrickle(pc, fromID, s)
|
||||
|
||||
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeOffer,
|
||||
SDP: payload.SDP,
|
||||
}); err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
sdpAnswer, err := pc.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := pc.SetLocalDescription(sdpAnswer); err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pc, s.SendTo(fromID, proto.SignalingPayload{Kind: proto.SigAnswer, SDP: sdpAnswer.SDP})
|
||||
}
|
||||
|
||||
// ── sender implements mesh.Anchor ────────────────────────────────────────────
|
||||
|
||||
type sender struct {
|
||||
id *crypto.Identity
|
||||
sendCh chan proto.AnchorMessage
|
||||
}
|
||||
|
||||
func (s *sender) SendTo(peerID proto.PeerID, payload proto.SignalingPayload) error {
|
||||
plaintext, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recipientCurve, err := curveFromPeerID(peerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive curve key for %s: %w", peerID.Short(), err)
|
||||
}
|
||||
sealed := crypto.SignalingBox(plaintext, recipientCurve, s.id.CurvePrivateKey())
|
||||
select {
|
||||
case s.sendCh <- proto.AnchorMessage{Type: proto.AnchorTo, To: string(peerID), Box: sealed}:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("send queue full")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sender) LocalID() proto.PeerID { return s.id.PeerID() }
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func openBox(b64box string, fromID proto.PeerID, localID *crypto.Identity) (proto.SignalingPayload, error) {
|
||||
senderCurve, err := curveFromPeerID(fromID)
|
||||
if err != nil {
|
||||
return proto.SignalingPayload{}, err
|
||||
}
|
||||
plaintext, err := crypto.SignalingOpen(b64box, senderCurve, localID.CurvePrivateKey())
|
||||
if err != nil {
|
||||
return proto.SignalingPayload{}, err
|
||||
}
|
||||
var p proto.SignalingPayload
|
||||
return p, json.Unmarshal(plaintext, &p)
|
||||
}
|
||||
|
||||
// curveFromPeerID derives an X25519 public key from a hex Ed25519 peer id
|
||||
// using the Montgomery conversion, identical to crypto.Identity.CurvePublicKey().
|
||||
func curveFromPeerID(id proto.PeerID) (*[32]byte, error) {
|
||||
pubBytes, err := hex.DecodeString(string(id))
|
||||
if err != nil || len(pubBytes) != 32 {
|
||||
return nil, fmt.Errorf("invalid peer id %q", id)
|
||||
}
|
||||
edPoint, err := new(edwards25519.Point).SetBytes(pubBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ed25519 point: %w", err)
|
||||
}
|
||||
mont := edPoint.BytesMontgomery()
|
||||
var out [32]byte
|
||||
copy(out[:], mont)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func hashNetName(name string) string {
|
||||
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func newPC() (*webrtc.PeerConnection, error) {
|
||||
return webrtc.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}},
|
||||
})
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func strPtr(s string) *string { return &s }
|
||||
func uint16Ptr(v uint16) *uint16 { return &v }
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -17,13 +19,15 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"filippo.io/edwards25519"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// b64 is the base64url encoder we use everywhere (no padding, URL-safe).
|
||||
// b64 is used only for storing the private key on disk and for symmetric nonces/ciphertexts.
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
// ── Identity ──────────────────────────────────────────────────────────────────
|
||||
@@ -93,10 +97,9 @@ func LoadOrCreate(dataDir, alias string) (*Identity, error) {
|
||||
return &Identity{privateKey: priv, PublicKey: pub, Alias: alias}, nil
|
||||
}
|
||||
|
||||
// PeerID returns the base64url encoding of the public key.
|
||||
// This is the peer's stable identifier.
|
||||
// PeerID returns the lowercase hex encoding of the 32-byte Ed25519 public key (YAW/2 §2).
|
||||
func (id *Identity) PeerID() proto.PeerID {
|
||||
return proto.PeerID(b64.EncodeToString(id.PublicKey))
|
||||
return proto.PeerID(hex.EncodeToString(id.PublicKey))
|
||||
}
|
||||
|
||||
// PeerInfo builds the PeerInfo struct for the handshake.
|
||||
@@ -104,24 +107,24 @@ func (id *Identity) PeerInfo() proto.PeerInfo {
|
||||
return proto.PeerInfo{
|
||||
ID: id.PeerID(),
|
||||
Alias: id.Alias,
|
||||
PublicKey: b64.EncodeToString(id.PublicKey),
|
||||
PublicKey: hex.EncodeToString(id.PublicKey),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Sign signs data with our Ed25519 private key. Returns base64url signature.
|
||||
// Sign signs data with our Ed25519 private key. Returns hex-encoded signature.
|
||||
func (id *Identity) Sign(data []byte) string {
|
||||
sig := ed25519.Sign(id.privateKey, data)
|
||||
return b64.EncodeToString(sig)
|
||||
return hex.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// Verify checks an Ed25519 signature against a base64url public key.
|
||||
func Verify(publicKeyB64 string, data []byte, sigB64 string) error {
|
||||
pubBytes, err := b64.DecodeString(publicKeyB64)
|
||||
// Verify checks an Ed25519 signature against a hex-encoded public key.
|
||||
func Verify(publicKeyHex string, data []byte, sigHex string) error {
|
||||
pubBytes, err := hex.DecodeString(publicKeyHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding public key: %w", err)
|
||||
}
|
||||
sigBytes, err := b64.DecodeString(sigB64)
|
||||
sigBytes, err := hex.DecodeString(sigHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding signature: %w", err)
|
||||
}
|
||||
@@ -131,6 +134,51 @@ func Verify(publicKeyB64 string, data []byte, sigB64 string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CurvePublicKey returns the X25519 public key derived from this Ed25519 identity (YAW/2 §3).
|
||||
func (id *Identity) CurvePublicKey() *[32]byte {
|
||||
edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey)
|
||||
mont := edPoint.BytesMontgomery()
|
||||
var out [32]byte
|
||||
copy(out[:], mont)
|
||||
return &out
|
||||
}
|
||||
|
||||
// CurvePrivateKey returns the X25519 private key derived from this Ed25519 identity (YAW/2 §3).
|
||||
// Uses the clamped SHA-512 first half of the Ed25519 seed, matching libsodium's conversion.
|
||||
func (id *Identity) CurvePrivateKey() *[32]byte {
|
||||
h := sha512.Sum512(id.privateKey[:32])
|
||||
h[0] &= 248
|
||||
h[31] &= 127
|
||||
h[31] |= 64
|
||||
var out [32]byte
|
||||
copy(out[:], h[:32])
|
||||
return &out
|
||||
}
|
||||
|
||||
// SignalingBox seals a plaintext signaling payload for a recipient using nacl/box (YAW/2 §5).
|
||||
// Returns base64(nonce(24) || ciphertext).
|
||||
func SignalingBox(plaintext []byte, recipientPub, senderPriv *[32]byte) string {
|
||||
var nonce [24]byte
|
||||
rand.Read(nonce[:]) //nolint:errcheck
|
||||
ct := box.Seal(nonce[:], plaintext, &nonce, recipientPub, senderPriv)
|
||||
return base64.StdEncoding.EncodeToString(ct)
|
||||
}
|
||||
|
||||
// SignalingOpen opens a nacl/box sealed by SignalingBox.
|
||||
func SignalingOpen(b64box string, senderPub, recipientPriv *[32]byte) ([]byte, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(b64box)
|
||||
if err != nil || len(raw) < 24 {
|
||||
return nil, errors.New("invalid box")
|
||||
}
|
||||
var nonce [24]byte
|
||||
copy(nonce[:], raw[:24])
|
||||
out, ok := box.Open(nil, raw[24:], &nonce, senderPub, recipientPriv)
|
||||
if !ok {
|
||||
return nil, errors.New("box open failed")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── X25519 ECDH ───────────────────────────────────────────────────────────────
|
||||
|
||||
// EphemeralKey is an X25519 keypair used for a single session.
|
||||
|
||||
71
internal/crypto/crypto_test.go
Normal file
71
internal/crypto/crypto_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPeerIDIsHex(t *testing.T) {
|
||||
id := newTestIdentity(t)
|
||||
peerID := string(id.PeerID())
|
||||
|
||||
if len(peerID) != 64 {
|
||||
t.Fatalf("PeerID length = %d, want 64", len(peerID))
|
||||
}
|
||||
if strings.ToLower(peerID) != peerID {
|
||||
t.Fatal("PeerID is not lowercase")
|
||||
}
|
||||
if _, err := hex.DecodeString(peerID); err != nil {
|
||||
t.Fatalf("PeerID is not valid hex: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignVerifyHex(t *testing.T) {
|
||||
id := newTestIdentity(t)
|
||||
msg := []byte("test message")
|
||||
|
||||
sig := id.Sign(msg)
|
||||
if err := Verify(string(id.PeerID()), msg, sig); err != nil {
|
||||
t.Fatalf("Verify failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalingBoxRoundTrip(t *testing.T) {
|
||||
alice := newTestIdentity(t)
|
||||
bob := newTestIdentity(t)
|
||||
|
||||
plaintext := []byte(`{"kind":"offer","sdp":"v=0..."}`)
|
||||
|
||||
sealed := SignalingBox(plaintext, bob.CurvePublicKey(), alice.CurvePrivateKey())
|
||||
got, err := SignalingOpen(sealed, alice.CurvePublicKey(), bob.CurvePrivateKey())
|
||||
if err != nil {
|
||||
t.Fatalf("SignalingOpen failed: %v", err)
|
||||
}
|
||||
if string(got) != string(plaintext) {
|
||||
t.Fatalf("got %q, want %q", got, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalingBoxTamperedFails(t *testing.T) {
|
||||
alice := newTestIdentity(t)
|
||||
bob := newTestIdentity(t)
|
||||
|
||||
sealed := SignalingBox([]byte("hello"), bob.CurvePublicKey(), alice.CurvePrivateKey())
|
||||
|
||||
// wrong sender public key
|
||||
carol := newTestIdentity(t)
|
||||
if _, err := SignalingOpen(sealed, carol.CurvePublicKey(), bob.CurvePrivateKey()); err == nil {
|
||||
t.Fatal("expected error with wrong sender key, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// newTestIdentity creates a fresh in-memory identity for testing.
|
||||
func newTestIdentity(t *testing.T) *Identity {
|
||||
t.Helper()
|
||||
id, err := LoadOrCreate(t.TempDir(), "test")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -5,6 +5,9 @@ package ipc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -16,8 +19,12 @@ import (
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// JoinFunc is called when the UI issues a join_network command.
|
||||
// It should connect to the anchor and block until done or ctx is cancelled.
|
||||
type JoinFunc func(ctx context.Context, networkName string)
|
||||
|
||||
// Run starts the IPC listener. Blocks until the listener fails.
|
||||
func Run(m *mesh.Mesh, port int) error {
|
||||
func Run(m *mesh.Mesh, port int, join JoinFunc) error {
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
@@ -31,21 +38,18 @@ func Run(m *mesh.Mesh, port int) error {
|
||||
return fmt.Errorf("ipc accept: %w", err)
|
||||
}
|
||||
log.Printf("ipc: UI client connected from %s", conn.RemoteAddr())
|
||||
go handleClient(conn, m)
|
||||
go handleClient(conn, m, join)
|
||||
}
|
||||
}
|
||||
|
||||
func handleClient(conn net.Conn, m *mesh.Mesh) {
|
||||
func handleClient(conn net.Conn, m *mesh.Mesh, join JoinFunc) {
|
||||
defer conn.Close()
|
||||
|
||||
// Subscribe to mesh events before anything else so we miss nothing.
|
||||
events := m.Subscribe()
|
||||
defer m.Unsubscribe(events)
|
||||
|
||||
// Channel to serialize writes from two goroutines (event pusher + reply sender).
|
||||
writeCh := make(chan []byte, 128)
|
||||
|
||||
// Writer goroutine — single goroutine owns the connection write side.
|
||||
go func() {
|
||||
w := bufio.NewWriter(conn)
|
||||
for line := range writeCh {
|
||||
@@ -57,7 +61,6 @@ func handleClient(conn net.Conn, m *mesh.Mesh) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Event pusher goroutine — forwards mesh events to the UI.
|
||||
go func() {
|
||||
for evt := range events {
|
||||
line, err := json.Marshal(evt)
|
||||
@@ -71,7 +74,6 @@ func handleClient(conn net.Conn, m *mesh.Mesh) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Send an initial state snapshot so the UI has something to render.
|
||||
send(writeCh, proto.IpcMessage{
|
||||
Type: proto.EvtStateSnapshot,
|
||||
LocalPeer: ptr(m.Identity.PeerInfo()),
|
||||
@@ -79,7 +81,11 @@ func handleClient(conn net.Conn, m *mesh.Mesh) {
|
||||
Rooms: []string{"general"},
|
||||
})
|
||||
|
||||
// Command reader loop.
|
||||
// Track an active network join so we can cancel it on leave_network.
|
||||
var (
|
||||
networkCancel context.CancelFunc
|
||||
)
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
var cmd proto.IpcMessage
|
||||
@@ -87,18 +93,12 @@ func handleClient(conn net.Conn, m *mesh.Mesh) {
|
||||
log.Printf("ipc: bad command: %v", err)
|
||||
continue
|
||||
}
|
||||
handleCommand(cmd, m, writeCh)
|
||||
}
|
||||
|
||||
close(writeCh)
|
||||
log.Printf("ipc: UI client disconnected")
|
||||
}
|
||||
|
||||
func handleCommand(cmd proto.IpcMessage, m *mesh.Mesh, writeCh chan<- []byte) {
|
||||
switch cmd.Type {
|
||||
|
||||
case proto.CmdSendMessage:
|
||||
msg := &proto.ChatMessage{
|
||||
Mid: randomHex(16),
|
||||
ID: uuid.NewString(),
|
||||
From: m.Identity.PeerID(),
|
||||
To: cmd.To,
|
||||
@@ -106,32 +106,30 @@ func handleCommand(cmd proto.IpcMessage, m *mesh.Mesh, writeCh chan<- []byte) {
|
||||
Body: cmd.Body,
|
||||
SentAt: time.Now(),
|
||||
}
|
||||
payload, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgChat,
|
||||
Chat: msg,
|
||||
})
|
||||
payload, err := json.Marshal(proto.PeerMessage{Type: proto.MsgChat, Chat: msg})
|
||||
if err != nil {
|
||||
return
|
||||
continue
|
||||
}
|
||||
m.Broadcast(payload)
|
||||
// Echo locally so the sender sees their own message.
|
||||
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: msg})
|
||||
|
||||
case proto.CmdConnect:
|
||||
if cmd.Addr == "" {
|
||||
send(writeCh, errMsg("connect: addr is required"))
|
||||
return
|
||||
case proto.CmdJoinNetwork:
|
||||
if cmd.NetworkName == "" {
|
||||
send(writeCh, errMsg("join_network: network_name is required"))
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
log.Printf("ipc: connecting to peer at %s", cmd.Addr)
|
||||
conn, err := net.DialTimeout("tcp", cmd.Addr, 10*time.Second)
|
||||
if err != nil {
|
||||
log.Printf("ipc: dial %s failed: %v", cmd.Addr, err)
|
||||
m.Emit(errMsg(fmt.Sprintf("connect to %s failed: %v", cmd.Addr, err)))
|
||||
return
|
||||
if networkCancel != nil {
|
||||
networkCancel() // leave any previous network
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
networkCancel = cancel
|
||||
go join(ctx, cmd.NetworkName)
|
||||
|
||||
case proto.CmdLeaveNetwork:
|
||||
if networkCancel != nil {
|
||||
networkCancel()
|
||||
networkCancel = nil
|
||||
}
|
||||
mesh.HandleConn(conn, m, true)
|
||||
}()
|
||||
|
||||
case proto.CmdGetState:
|
||||
send(writeCh, proto.IpcMessage{
|
||||
@@ -147,6 +145,13 @@ func handleCommand(cmd proto.IpcMessage, m *mesh.Mesh, writeCh chan<- []byte) {
|
||||
default:
|
||||
send(writeCh, errMsg(fmt.Sprintf("unknown command: %s", cmd.Type)))
|
||||
}
|
||||
}
|
||||
|
||||
if networkCancel != nil {
|
||||
networkCancel()
|
||||
}
|
||||
close(writeCh)
|
||||
log.Printf("ipc: UI client disconnected")
|
||||
}
|
||||
|
||||
func send(ch chan<- []byte, msg proto.IpcMessage) {
|
||||
@@ -165,3 +170,9 @@ func errMsg(s string) proto.IpcMessage {
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b) //nolint:errcheck
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
@@ -1,194 +1,197 @@
|
||||
// Package mesh/peer handles individual peer TCP connections.
|
||||
// Package mesh/peer exports WebRTC DataChannel helpers used by the anchor client.
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"github.com/pion/webrtc/v3"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// HandleConn runs the full lifecycle of one peer connection:
|
||||
//
|
||||
// 1. Handshake (Hello / HelloAck)
|
||||
// 2. ECDH → session key
|
||||
// 3. Register in mesh
|
||||
// 4. Concurrent read + write loops
|
||||
// 5. Unregister on disconnect
|
||||
//
|
||||
// Call this in a goroutine for both inbound and outbound connections.
|
||||
func HandleConn(conn net.Conn, m *Mesh, weInitiated bool) {
|
||||
defer conn.Close()
|
||||
addr := conn.RemoteAddr().String()
|
||||
log.Printf("peer: connected to %s (we initiated: %v)", addr, weInitiated)
|
||||
// Anchor is the signaling channel used to exchange sealed offers/answers/candidates.
|
||||
// Implemented by internal/anchor.Client.
|
||||
type Anchor interface {
|
||||
SendTo(peerID proto.PeerID, payload proto.SignalingPayload) error
|
||||
LocalID() proto.PeerID
|
||||
}
|
||||
|
||||
session, peerInfo, err := handshake(conn, m.Identity, weInitiated)
|
||||
if err != nil {
|
||||
log.Printf("peer: handshake with %s failed: %v", addr, err)
|
||||
return
|
||||
}
|
||||
log.Printf("peer: handshake complete with %s (%s)", peerInfo.Alias, peerInfo.ID.Short())
|
||||
|
||||
// Channel for outbound messages (IPC handler writes here)
|
||||
// WireDataChannel sets up open/message/close handlers on a "yaw" DataChannel.
|
||||
// Must be called before the DataChannel opens.
|
||||
func WireDataChannel(
|
||||
dc *webrtc.DataChannel,
|
||||
pc *webrtc.PeerConnection,
|
||||
peerID proto.PeerID,
|
||||
id *crypto.Identity,
|
||||
m *Mesh,
|
||||
) {
|
||||
sendCh := make(chan []byte, 64)
|
||||
peerConn := &PeerConn{Info: *peerInfo, Send: sendCh}
|
||||
m.AddPeer(peerConn)
|
||||
defer m.RemovePeer(peerInfo.ID)
|
||||
|
||||
// Outbound writer goroutine
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
w := bufio.NewWriter(conn)
|
||||
for payload := range sendCh {
|
||||
nonce, ct, err := session.Encrypt(payload)
|
||||
if err != nil {
|
||||
log.Printf("peer: encrypt error: %v", err)
|
||||
continue
|
||||
dc.OnOpen(func() {
|
||||
log.Printf("peer: DataChannel open with %s", peerID.Short())
|
||||
|
||||
// Send hello — bind our identity to this DTLS session.
|
||||
localFP, remoteFP := dtlsFingerprints(pc)
|
||||
bindBytes := proto.HelloBindString(localFP, remoteFP)
|
||||
hello := proto.HelloMessage{
|
||||
Type: "hello",
|
||||
ID: string(id.PeerID()),
|
||||
Nick: id.Alias,
|
||||
Caps: []string{"chat", "file"},
|
||||
Sig: id.Sign(bindBytes),
|
||||
}
|
||||
env := proto.Envelope{Nonce: nonce, Payload: ct}
|
||||
line, _ := json.Marshal(env)
|
||||
line = append(line, '\n')
|
||||
if _, err := w.Write(line); err != nil {
|
||||
helloJSON, _ := json.Marshal(hello)
|
||||
if err := dc.SendText(string(helloJSON)); err != nil {
|
||||
log.Printf("peer: send hello to %s: %v", peerID.Short(), err)
|
||||
return
|
||||
}
|
||||
|
||||
peerConn := &PeerConn{
|
||||
Info: proto.PeerInfo{ID: peerID, Alias: string(peerID.Short())},
|
||||
Send: sendCh,
|
||||
}
|
||||
m.AddPeer(peerConn)
|
||||
|
||||
go func() {
|
||||
for payload := range sendCh {
|
||||
if err := dc.SendText(string(payload)); err != nil {
|
||||
log.Printf("peer: send to %s: %v", peerID.Short(), err)
|
||||
return
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
// Inbound reader loop (this goroutine)
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
var env proto.Envelope
|
||||
if err := json.Unmarshal(scanner.Bytes(), &env); err != nil {
|
||||
log.Printf("peer: bad envelope from %s: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
plaintext, err := session.Decrypt(env.Nonce, env.Payload)
|
||||
if err != nil {
|
||||
log.Printf("peer: decrypt error from %s: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
var msg proto.PeerMessage
|
||||
if err := json.Unmarshal(plaintext, &msg); err != nil {
|
||||
log.Printf("peer: bad peer message from %s: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
handleMessage(msg, peerInfo, m)
|
||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
if msg.IsString {
|
||||
handleDCMessage(msg.Data, peerID, id, m)
|
||||
}
|
||||
})
|
||||
|
||||
dc.OnClose(func() {
|
||||
log.Printf("peer: DataChannel closed with %s", peerID.Short())
|
||||
close(sendCh)
|
||||
<-done
|
||||
log.Printf("peer: disconnected from %s", addr)
|
||||
m.RemovePeer(peerID)
|
||||
pc.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// handshake performs the Ed25519-authenticated X25519 key exchange.
|
||||
// Returns the symmetric session and the remote peer's info.
|
||||
func handshake(conn net.Conn, id *crypto.Identity, weInitiated bool) (*crypto.Session, *proto.PeerInfo, error) {
|
||||
conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
defer conn.SetDeadline(time.Time{})
|
||||
|
||||
ek, err := crypto.GenerateEphemeral()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
// WireCandidateTrickle seals and forwards each ICE candidate via the anchor as it arrives.
|
||||
func WireCandidateTrickle(pc *webrtc.PeerConnection, peerID proto.PeerID, anchor Anchor) {
|
||||
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ourHello := proto.Hello{
|
||||
Version: 1,
|
||||
Peer: id.PeerInfo(),
|
||||
EphemeralKey: ek.PublicKeyB64(),
|
||||
Signature: id.Sign([]byte(ek.PublicKeyB64())),
|
||||
init := c.ToJSON()
|
||||
mid := ""
|
||||
if init.SDPMid != nil {
|
||||
mid = *init.SDPMid
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(conn)
|
||||
dec := json.NewDecoder(conn)
|
||||
|
||||
if weInitiated {
|
||||
// We go first
|
||||
if err := enc.Encode(ourHello); err != nil {
|
||||
return nil, nil, fmt.Errorf("sending hello: %w", err)
|
||||
mline := 0
|
||||
if init.SDPMLineIndex != nil {
|
||||
mline = int(*init.SDPMLineIndex)
|
||||
}
|
||||
var ack proto.HelloAck
|
||||
if err := dec.Decode(&ack); err != nil {
|
||||
return nil, nil, fmt.Errorf("reading hello ack: %w", err)
|
||||
if err := anchor.SendTo(peerID, proto.SignalingPayload{
|
||||
Kind: proto.SigCandidate,
|
||||
Cand: init.Candidate,
|
||||
Mid: mid,
|
||||
MLine: mline,
|
||||
}); err != nil {
|
||||
log.Printf("peer: trickle candidate to %s: %v", peerID.Short(), err)
|
||||
}
|
||||
if err := crypto.Verify(ack.Peer.PublicKey, []byte(ack.EphemeralKey), ack.Signature); err != nil {
|
||||
return nil, nil, fmt.Errorf("bad ack signature: %w", err)
|
||||
}
|
||||
secret, err := ek.SharedSecret(ack.EphemeralKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return crypto.NewSession(secret), &ack.Peer, nil
|
||||
}
|
||||
|
||||
// They go first — read their Hello, then send our Ack
|
||||
var theirHello proto.Hello
|
||||
if err := dec.Decode(&theirHello); err != nil {
|
||||
return nil, nil, fmt.Errorf("reading hello: %w", err)
|
||||
}
|
||||
if err := crypto.Verify(theirHello.Peer.PublicKey, []byte(theirHello.EphemeralKey), theirHello.Signature); err != nil {
|
||||
return nil, nil, fmt.Errorf("bad hello signature: %w", err)
|
||||
}
|
||||
|
||||
ack := proto.HelloAck{
|
||||
Peer: id.PeerInfo(),
|
||||
EphemeralKey: ek.PublicKeyB64(),
|
||||
Signature: id.Sign([]byte(ek.PublicKeyB64())),
|
||||
}
|
||||
if err := enc.Encode(ack); err != nil {
|
||||
return nil, nil, fmt.Errorf("sending ack: %w", err)
|
||||
}
|
||||
|
||||
secret, err := ek.SharedSecret(theirHello.EphemeralKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return crypto.NewSession(secret), &theirHello.Peer, nil
|
||||
})
|
||||
}
|
||||
|
||||
// handleMessage dispatches an incoming decrypted peer message.
|
||||
func handleMessage(msg proto.PeerMessage, from *proto.PeerInfo, m *Mesh) {
|
||||
// handleDCMessage dispatches a raw DataChannel text message.
|
||||
func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m *Mesh) {
|
||||
var probe struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &probe); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if probe.Type == "hello" {
|
||||
var hello proto.HelloMessage
|
||||
if err := json.Unmarshal(data, &hello); err != nil {
|
||||
log.Printf("peer: bad hello from %s: %v", from.Short(), err)
|
||||
return
|
||||
}
|
||||
// Update alias once we have the verified nick.
|
||||
m.mu.Lock()
|
||||
if conn, ok := m.peers[from]; ok {
|
||||
conn.Info.Alias = hello.Nick
|
||||
conn.Info.PublicKey = hello.ID
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtSessionReady,
|
||||
PeerID: peerIDPtr(from),
|
||||
Nick: hello.Nick,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var msg proto.PeerMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
log.Printf("peer: bad message from %s: %v", from.Short(), err)
|
||||
return
|
||||
}
|
||||
dispatchPeerMessage(msg, from, m)
|
||||
}
|
||||
|
||||
func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
||||
switch msg.Type {
|
||||
case proto.MsgChat:
|
||||
if msg.Chat == nil {
|
||||
return
|
||||
if msg.Chat != nil {
|
||||
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: msg.Chat})
|
||||
}
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtMessageReceived,
|
||||
Message: msg.Chat,
|
||||
})
|
||||
|
||||
case proto.MsgPeerGossip:
|
||||
if msg.Gossip == nil {
|
||||
return
|
||||
if msg.Gossip != nil {
|
||||
log.Printf("mesh: gossip from %s: %d peer hints", from.Short(), len(msg.Gossip.Peers))
|
||||
}
|
||||
log.Printf("mesh: gossip from %s: %d peer hints", from.Alias, len(msg.Gossip.Peers))
|
||||
// TODO: attempt connections to new peers
|
||||
|
||||
case proto.MsgPing:
|
||||
log.Printf("mesh: ping from %s", from.Alias)
|
||||
// TODO: send pong back through the send channel
|
||||
|
||||
log.Printf("mesh: ping from %s", from.Short())
|
||||
case proto.MsgPong:
|
||||
log.Printf("mesh: pong from %s", from.Alias)
|
||||
|
||||
log.Printf("mesh: pong from %s", from.Short())
|
||||
case proto.MsgFileOffer:
|
||||
if msg.FileOffer == nil {
|
||||
return
|
||||
}
|
||||
if msg.FileOffer != nil {
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtIncomingFile,
|
||||
PeerID: &from.ID,
|
||||
PeerID: peerIDPtr(from),
|
||||
Offer: msg.FileOffer,
|
||||
})
|
||||
|
||||
}
|
||||
default:
|
||||
log.Printf("mesh: unknown message type %q from %s", msg.Type, from.Alias)
|
||||
log.Printf("mesh: unknown message type %q from %s", msg.Type, from.Short())
|
||||
}
|
||||
}
|
||||
|
||||
func dtlsFingerprints(pc *webrtc.PeerConnection) (local, remote []byte) {
|
||||
if ld := pc.LocalDescription(); ld != nil {
|
||||
local = fingerprintFromSDP(ld.SDP)
|
||||
}
|
||||
if rd := pc.RemoteDescription(); rd != nil {
|
||||
remote = fingerprintFromSDP(rd.SDP)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func fingerprintFromSDP(sdp string) []byte {
|
||||
for _, line := range strings.Split(sdp, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "a=fingerprint:sha-256 ") {
|
||||
hexStr := strings.ReplaceAll(strings.TrimPrefix(line, "a=fingerprint:sha-256 "), ":", "")
|
||||
if b, err := hex.DecodeString(hexStr); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func peerIDPtr(p proto.PeerID) *proto.PeerID { return &p }
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// Package nat handles relay registration and future hole-punching.
|
||||
package nat
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/waste-go/internal/mesh"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// Run connects to the relay server (if configured) and keeps the connection alive.
|
||||
// Pass an empty relayAddr to skip relay entirely (LAN-only mode).
|
||||
func Run(m *mesh.Mesh, relayAddr string) error {
|
||||
if relayAddr == "" {
|
||||
log.Println("nat: no relay configured, running in LAN-only mode")
|
||||
select {} // block forever — task stays alive
|
||||
}
|
||||
|
||||
for {
|
||||
log.Printf("nat: connecting to relay at %s", relayAddr)
|
||||
if err := connectRelay(relayAddr, m); err != nil {
|
||||
log.Printf("nat: relay error: %v", err)
|
||||
}
|
||||
log.Printf("nat: reconnecting to relay in 10s...")
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func connectRelay(addr string, m *mesh.Mesh) error {
|
||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial relay: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
id := m.Identity
|
||||
enc := json.NewEncoder(conn)
|
||||
dec := json.NewDecoder(bufio.NewReader(conn))
|
||||
|
||||
// Register with the relay
|
||||
reg := proto.RelayMessage{
|
||||
Type: proto.RelayRegister,
|
||||
Peer: ptr(id.PeerInfo()),
|
||||
Signature: id.Sign([]byte(id.PeerID())),
|
||||
}
|
||||
if err := enc.Encode(reg); err != nil {
|
||||
return fmt.Errorf("sending register: %w", err)
|
||||
}
|
||||
log.Printf("nat: registered with relay as %s", id.PeerID().Short())
|
||||
|
||||
// Ask for the current peer list (bootstrap)
|
||||
if err := enc.Encode(proto.RelayMessage{Type: proto.RelayListPeers}); err != nil {
|
||||
return fmt.Errorf("sending list_peers: %w", err)
|
||||
}
|
||||
|
||||
// Read relay messages
|
||||
for {
|
||||
var msg proto.RelayMessage
|
||||
if err := dec.Decode(&msg); err != nil {
|
||||
return fmt.Errorf("reading relay message: %w", err)
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case proto.RelayPeerList:
|
||||
log.Printf("nat: relay gave us %d peer hints", len(msg.Peers))
|
||||
// TODO: attempt direct connections to each peer hint
|
||||
|
||||
case proto.RelayForwarded:
|
||||
from := "unknown"
|
||||
if msg.From != nil {
|
||||
from = msg.From.Short()
|
||||
}
|
||||
log.Printf("nat: relayed envelope from %s — direct connection not yet implemented", from)
|
||||
// TODO: decrypt and process as PeerMessage
|
||||
|
||||
case proto.RelayError:
|
||||
log.Printf("nat: relay error: %s", msg.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -1,62 +1,36 @@
|
||||
// Package proto defines all wire types shared between the daemon and relay.
|
||||
// Package proto defines all wire types shared between the daemon and anchor.
|
||||
// Everything on the wire is newline-delimited JSON.
|
||||
// Binary data (keys, signatures, ciphertext) is base64url encoded.
|
||||
// Binary data (keys, signatures) is hex-encoded; signaling boxes are base64.
|
||||
package proto
|
||||
|
||||
import "time"
|
||||
|
||||
// ── Identity ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// PeerID is a peer's stable identity: their Ed25519 public key, base64url encoded.
|
||||
// PeerID is a peer's stable identity: lowercase hex of the 32-byte Ed25519 public key (64 chars).
|
||||
// This IS the peer — display names are advisory only and unauthenticated.
|
||||
type PeerID string
|
||||
|
||||
// Short returns the first 8 characters, useful for display.
|
||||
// Short returns the first 16 hex chars grouped in 4s: "a1b2 c3d4 e5f6 0718".
|
||||
func (p PeerID) Short() string {
|
||||
if len(p) < 8 {
|
||||
return string(p)
|
||||
s := string(p)
|
||||
if len(s) < 16 {
|
||||
return s
|
||||
}
|
||||
return string(p)[:8]
|
||||
return s[0:4] + " " + s[4:8] + " " + s[8:12] + " " + s[12:16]
|
||||
}
|
||||
|
||||
// PeerInfo is a peer's self-description. Included in the Hello handshake.
|
||||
// PeerInfo is a peer's self-description, included in the hello confirmation.
|
||||
type PeerInfo struct {
|
||||
ID PeerID `json:"id"`
|
||||
Alias string `json:"alias"` // advisory, not authenticated
|
||||
PublicKey string `json:"public_key"` // Ed25519 pubkey, base64url
|
||||
PublicKey string `json:"public_key"` // Ed25519 pubkey, hex
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ── Handshake ─────────────────────────────────────────────────────────────────
|
||||
// ── Peer-to-peer message types (over the "yaw" DataChannel) ──────────────────
|
||||
|
||||
// Hello is the first message sent on a new TCP connection.
|
||||
type Hello struct {
|
||||
Version int `json:"version"`
|
||||
Peer PeerInfo `json:"peer"`
|
||||
EphemeralKey string `json:"ephemeral_key"` // X25519 pubkey, base64url
|
||||
Signature string `json:"signature"` // Ed25519 sig over ephemeral_key
|
||||
}
|
||||
|
||||
// HelloAck is the response to Hello, completing the handshake.
|
||||
type HelloAck struct {
|
||||
Peer PeerInfo `json:"peer"`
|
||||
EphemeralKey string `json:"ephemeral_key"`
|
||||
Signature string `json:"signature"`
|
||||
RelayCapable bool `json:"relay_capable"`
|
||||
}
|
||||
|
||||
// ── Encrypted envelope ────────────────────────────────────────────────────────
|
||||
|
||||
// Envelope wraps all post-handshake messages.
|
||||
// The payload is ChaCha20-Poly1305 encrypted.
|
||||
type Envelope struct {
|
||||
Nonce string `json:"nonce"` // 12 bytes, base64url
|
||||
Payload string `json:"payload"` // ciphertext, base64url
|
||||
}
|
||||
|
||||
// ── Peer-to-peer message types ────────────────────────────────────────────────
|
||||
|
||||
// MsgType identifies the kind of peer message inside an Envelope.
|
||||
// MsgType identifies the kind of peer message.
|
||||
type MsgType string
|
||||
|
||||
const (
|
||||
@@ -64,12 +38,13 @@ const (
|
||||
MsgPeerGossip MsgType = "peer_gossip"
|
||||
MsgFileOffer MsgType = "file_offer"
|
||||
MsgFileResp MsgType = "file_response"
|
||||
MsgFileChunk MsgType = "file_chunk"
|
||||
MsgFileDone MsgType = "file_done"
|
||||
MsgPing MsgType = "ping"
|
||||
MsgPong MsgType = "pong"
|
||||
)
|
||||
|
||||
// PeerMessage is the top-level container decoded from inside an Envelope.
|
||||
// PeerMessage is the top-level container sent over the "yaw" DataChannel.
|
||||
// File chunks go over a separate binary DataChannel labeled "f:<xid>".
|
||||
type PeerMessage struct {
|
||||
Type MsgType `json:"type"`
|
||||
|
||||
@@ -78,13 +53,14 @@ type PeerMessage struct {
|
||||
Gossip *PeerGossip `json:"gossip,omitempty"`
|
||||
FileOffer *FileOffer `json:"file_offer,omitempty"`
|
||||
FileResp *FileResponse `json:"file_response,omitempty"`
|
||||
FileChunk *FileChunk `json:"file_chunk,omitempty"`
|
||||
FileDone *FileDone `json:"file_done,omitempty"`
|
||||
Seq *uint64 `json:"seq,omitempty"` // for ping/pong
|
||||
}
|
||||
|
||||
// ChatMessage is a message to a room or a DM.
|
||||
type ChatMessage struct {
|
||||
ID string `json:"id"`
|
||||
Mid string `json:"mid"` // random 16-byte hex, for deduplication (YAW/2 §8)
|
||||
ID string `json:"id"` // internal uuid, kept for local use
|
||||
From PeerID `json:"from"`
|
||||
To *PeerID `json:"to,omitempty"` // nil = broadcast to room
|
||||
Room string `json:"room"`
|
||||
@@ -100,64 +76,102 @@ type PeerGossip struct {
|
||||
// GossipEntry is one peer hint shared via gossip.
|
||||
type GossipEntry struct {
|
||||
Peer PeerInfo `json:"peer"`
|
||||
AddrHint string `json:"addr_hint"` // IP:port, may be behind NAT
|
||||
AddrHint string `json:"addr_hint"` // IP:port hint, may be behind NAT
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
// FileOffer initiates a file transfer.
|
||||
type FileOffer struct {
|
||||
TransferID string `json:"transfer_id"`
|
||||
Mid string `json:"mid"` // dedup id
|
||||
Xid string `json:"xid"` // transfer id, used as DataChannel label "f:<xid>"
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
SHA256 string `json:"sha256"` // hex
|
||||
}
|
||||
|
||||
// FileResponse accepts or declines a FileOffer.
|
||||
type FileResponse struct {
|
||||
TransferID string `json:"transfer_id"`
|
||||
Mid string `json:"mid"`
|
||||
Xid string `json:"xid"`
|
||||
Accepted bool `json:"accepted"`
|
||||
}
|
||||
|
||||
// FileChunk is one piece of a file transfer.
|
||||
type FileChunk struct {
|
||||
TransferID string `json:"transfer_id"`
|
||||
Seq uint32 `json:"seq"`
|
||||
Data string `json:"data"` // base64url
|
||||
IsLast bool `json:"is_last"`
|
||||
// FileDone signals that all chunks have been sent. Receiver verifies SHA256.
|
||||
type FileDone struct {
|
||||
Mid string `json:"mid"`
|
||||
Xid string `json:"xid"`
|
||||
SHA256 string `json:"sha256"` // hex
|
||||
}
|
||||
|
||||
// ── Relay protocol ────────────────────────────────────────────────────────────
|
||||
// ── DataChannel hello (YAW/2 §6) ─────────────────────────────────────────────
|
||||
|
||||
// RelayMsgType identifies relay wire messages.
|
||||
type RelayMsgType string
|
||||
// HelloMessage is the first message sent on the "yaw" DataChannel.
|
||||
// The signature binds this identity to the specific DTLS session.
|
||||
type HelloMessage struct {
|
||||
Type string `json:"type"` // always "hello"
|
||||
ID string `json:"id"` // hex pubkey
|
||||
Nick string `json:"nick"` // alias
|
||||
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
||||
Sig string `json:"sig"` // hex ed25519 sig over HelloBindString
|
||||
}
|
||||
|
||||
// HelloBindString returns the bytes the hello signature covers:
|
||||
// "yaw/2 bind" || localDTLSFingerprint(32 bytes) || remoteDTLSFingerprint(32 bytes)
|
||||
func HelloBindString(localFP, remoteFP []byte) []byte {
|
||||
buf := []byte("yaw/2 bind")
|
||||
buf = append(buf, localFP...)
|
||||
buf = append(buf, remoteFP...)
|
||||
return buf
|
||||
}
|
||||
|
||||
// ── Signaling payload (sealed inside nacl/box, exchanged via anchor) ──────────
|
||||
|
||||
// SignalingKind identifies the kind of sealed signaling payload.
|
||||
type SignalingKind string
|
||||
|
||||
const (
|
||||
RelayRegister RelayMsgType = "register"
|
||||
RelayForward RelayMsgType = "forward"
|
||||
RelayListPeers RelayMsgType = "list_peers"
|
||||
RelayForwarded RelayMsgType = "forwarded"
|
||||
RelayPeerList RelayMsgType = "peer_list"
|
||||
RelayError RelayMsgType = "error"
|
||||
SigOffer SignalingKind = "offer"
|
||||
SigAnswer SignalingKind = "answer"
|
||||
SigCandidate SignalingKind = "candidate"
|
||||
SigBye SignalingKind = "bye"
|
||||
)
|
||||
|
||||
// RelayMessage is used for both client→relay and relay→client.
|
||||
type RelayMessage struct {
|
||||
Type RelayMsgType `json:"type"`
|
||||
// SignalingPayload is the JSON plaintext sealed inside a crypto_box (YAW/2 §5).
|
||||
type SignalingPayload struct {
|
||||
Kind SignalingKind `json:"kind"`
|
||||
SDP string `json:"sdp,omitempty"` // offer / answer
|
||||
Cand string `json:"cand,omitempty"` // trickle ICE candidate line
|
||||
Mid string `json:"mid,omitempty"` // media stream id for candidate
|
||||
MLine int `json:"mline,omitempty"` // media line index
|
||||
}
|
||||
|
||||
// register
|
||||
Peer *PeerInfo `json:"peer,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
// ── Anchor WebSocket wire types (YAW/2 §5) ────────────────────────────────────
|
||||
|
||||
// forward / forwarded
|
||||
To *PeerID `json:"to,omitempty"`
|
||||
From *PeerID `json:"from,omitempty"`
|
||||
Envelope *Envelope `json:"envelope,omitempty"`
|
||||
// AnchorMsgType identifies anchor WebSocket messages.
|
||||
type AnchorMsgType string
|
||||
|
||||
// peer_list
|
||||
Peers []GossipEntry `json:"peers,omitempty"`
|
||||
const (
|
||||
AnchorChallenge AnchorMsgType = "challenge"
|
||||
AnchorJoin AnchorMsgType = "join"
|
||||
AnchorJoined AnchorMsgType = "joined"
|
||||
AnchorPeerJoin AnchorMsgType = "peer-join"
|
||||
AnchorPeerLeave AnchorMsgType = "peer-leave"
|
||||
AnchorTo AnchorMsgType = "to"
|
||||
AnchorFrom AnchorMsgType = "from"
|
||||
AnchorNoPeer AnchorMsgType = "no-peer"
|
||||
)
|
||||
|
||||
// error
|
||||
Message string `json:"message,omitempty"`
|
||||
// AnchorMessage covers all WebSocket frames to/from the anchor.
|
||||
type AnchorMessage struct {
|
||||
Type AnchorMsgType `json:"type"`
|
||||
Nonce string `json:"nonce,omitempty"` // challenge nonce, hex
|
||||
ID string `json:"id,omitempty"` // peer hex id
|
||||
Net string `json:"net,omitempty"` // hashed network name
|
||||
Sig string `json:"sig,omitempty"` // ed25519 sig over (nonce||net), hex
|
||||
Peers []string `json:"peers,omitempty"` // joined: list of peer hex ids in network
|
||||
To string `json:"to,omitempty"` // target peer hex id
|
||||
From string `json:"from,omitempty"` // sender peer hex id
|
||||
Box string `json:"box,omitempty"` // base64 nacl/box sealed payload
|
||||
}
|
||||
|
||||
// ── IPC protocol (daemon ↔ local UI) ─────────────────────────────────────────
|
||||
@@ -168,7 +182,8 @@ type IpcMsgType string
|
||||
const (
|
||||
// Commands (UI → daemon)
|
||||
CmdSendMessage IpcMsgType = "send_message"
|
||||
CmdConnect IpcMsgType = "connect"
|
||||
CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext)
|
||||
CmdLeaveNetwork IpcMsgType = "leave_network"
|
||||
CmdGetState IpcMsgType = "get_state"
|
||||
CmdSendFile IpcMsgType = "send_file"
|
||||
|
||||
@@ -176,6 +191,7 @@ const (
|
||||
EvtMessageReceived IpcMsgType = "message_received"
|
||||
EvtPeerConnected IpcMsgType = "peer_connected"
|
||||
EvtPeerDisconnected IpcMsgType = "peer_disconnected"
|
||||
EvtSessionReady IpcMsgType = "session_ready" // DataChannel open + hello verified
|
||||
EvtIncomingFile IpcMsgType = "incoming_file"
|
||||
EvtFileProgress IpcMsgType = "file_progress"
|
||||
EvtStateSnapshot IpcMsgType = "state_snapshot"
|
||||
@@ -191,8 +207,8 @@ type IpcMessage struct {
|
||||
To *PeerID `json:"to,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
|
||||
// connect
|
||||
Addr string `json:"addr,omitempty"`
|
||||
// join_network / leave_network
|
||||
NetworkName string `json:"network_name,omitempty"`
|
||||
|
||||
// send_file
|
||||
Path string `json:"path,omitempty"`
|
||||
@@ -200,6 +216,7 @@ type IpcMessage struct {
|
||||
// events
|
||||
Peer *PeerInfo `json:"peer,omitempty"`
|
||||
PeerID *PeerID `json:"peer_id,omitempty"`
|
||||
Nick string `json:"nick,omitempty"`
|
||||
Message *ChatMessage `json:"message,omitempty"`
|
||||
Offer *FileOffer `json:"offer,omitempty"`
|
||||
TransferID string `json:"transfer_id,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user