Compare commits
5 Commits
0e8ddbf4f4
...
d233f4d79e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d233f4d79e | ||
|
|
95fd29ae8d | ||
|
|
340735f992 | ||
|
|
1308082c7b | ||
|
|
31e13fd509 |
173
EXTENSIONS.md
Normal file
173
EXTENSIONS.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# waste-go Protocol Extensions
|
||||
|
||||
These are additive extensions to [YAW/2](PROTOCOL.md) implemented by waste-go.
|
||||
They do **not** break compatibility — YAW/2-only peers silently ignore all new
|
||||
fields. Where a waste-go peer connects to a YAW/2-only peer, the extension
|
||||
simply has no effect on that peer.
|
||||
|
||||
---
|
||||
|
||||
## EXT-001 — Signed Invites
|
||||
|
||||
**Status:** implemented
|
||||
**Affects:** `waste:` invite format, `hello` DataChannel message
|
||||
|
||||
### Motivation
|
||||
|
||||
The base YAW/2 network model is open to anyone who knows the anchor URL and
|
||||
network name (or hash). This extension adds opt-in cryptographic membership
|
||||
gating: invites are signed by an existing peer, and peers that enforce
|
||||
`RequireInvite` reject hellos that carry no valid signed invite.
|
||||
|
||||
### Invite format changes
|
||||
|
||||
The `waste:` invite payload (base64-encoded JSON) gains two optional fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"anchor": "wss://...",
|
||||
"network": "friends",
|
||||
"net": "<64-hex SHA-256(yaw2-net:name)>",
|
||||
"inviter": "<64-hex Ed25519 pubkey of signing peer>",
|
||||
"sig": "<hex Ed25519 signature>"
|
||||
}
|
||||
```
|
||||
|
||||
The signature covers the following bytes (null-separated):
|
||||
|
||||
```
|
||||
anchor \x00 network \x00 net \x00 inviter
|
||||
```
|
||||
|
||||
Unsigned invites (`inviter`/`sig` absent) remain valid for backward compat.
|
||||
|
||||
### Hello message extension
|
||||
|
||||
The YAW/2 §6 hello message gains one optional field:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "hello",
|
||||
"id": "<hex pubkey>",
|
||||
"nick": "alice",
|
||||
"caps": ["chat", "file"],
|
||||
"sig": "<DTLS binding sig>",
|
||||
"invite": "waste:eyJ..."
|
||||
}
|
||||
```
|
||||
|
||||
`invite` carries the full `waste:` string the connecting peer used to join.
|
||||
YAW/2-only peers ignore this field.
|
||||
|
||||
### Enforcement
|
||||
|
||||
Per-network flag `RequireInvite` (set via `join_network` IPC command).
|
||||
When enabled:
|
||||
|
||||
1. A peer that presents no `invite` in hello is disconnected immediately.
|
||||
2. A peer that presents an invite with no signature is disconnected.
|
||||
3. A peer whose invite signature is invalid is disconnected.
|
||||
4. A peer whose invite was signed by an unknown peer ID (not in the store or
|
||||
currently connected) is disconnected.
|
||||
|
||||
The inviter's key must be a **known peer** — i.e. previously connected and
|
||||
stored in the per-network SQLite store, or currently connected. This forms a
|
||||
chain of trust: Alice (founder) invites Bob; Bob's key is now known; Bob can
|
||||
invite Carol, whose invite Alice will also accept.
|
||||
|
||||
**Default:** off. Networks opt in. Existing networks with no RequireInvite
|
||||
behave exactly as before.
|
||||
|
||||
---
|
||||
|
||||
## EXT-002 — Hash-based Hang Link
|
||||
|
||||
**Status:** implemented
|
||||
**Affects:** web UI URL handling only, no wire changes
|
||||
|
||||
### Motivation
|
||||
|
||||
A shareable URL that pre-fills the join form without conveying cryptographic
|
||||
membership. Suitable for public announcements ("come hang out here"). The
|
||||
fragment is never sent to the server, keeping the network name opaque to
|
||||
server logs and HTTP intermediaries.
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
https://host/#waste:eyJ...
|
||||
```
|
||||
|
||||
The fragment payload is the standard `waste:` base64 JSON with only `network`
|
||||
and `anchor` fields — no `inviter`, no `sig`. This does **not** grant
|
||||
membership on networks with `RequireInvite` enabled; it only pre-fills the
|
||||
join form.
|
||||
|
||||
The web UI generates hang links via the 🔗 button in the Networks sidebar
|
||||
section. Arriving users see the join form pre-populated and still need a
|
||||
proper signed invite (if the network enforces it) to be accepted by peers.
|
||||
|
||||
---
|
||||
|
||||
## EXT-003 — Multi-Share Configuration
|
||||
|
||||
**Status:** implemented
|
||||
**Affects:** IPC protocol only, no peer-to-peer wire changes
|
||||
|
||||
### New IPC commands
|
||||
|
||||
```jsonc
|
||||
{"type":"add_share","path":"/home/alice/Music"} // global
|
||||
{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // scoped
|
||||
{"type":"remove_share","path":"/home/alice/Music"}
|
||||
{"type":"list_shares"}
|
||||
```
|
||||
|
||||
### New IPC event
|
||||
|
||||
```jsonc
|
||||
{"type":"shares_list","shares":[{"path":"...","networks":["*"]}]}
|
||||
```
|
||||
|
||||
### Persistence
|
||||
|
||||
`shares.json` in the data directory (next to `identity.json`). Each entry:
|
||||
|
||||
```json
|
||||
{ "path": "/absolute/path", "networks": ["*"] }
|
||||
```
|
||||
|
||||
`networks: ["*"]` = global (all networks). Specific network IDs = scoped.
|
||||
Coexists with the legacy `set_share_dir` single-dir mechanism.
|
||||
|
||||
File listings returned by `get_file_list` and `MsgFileListReq` include
|
||||
entries from all applicable share roots, with relative `path` fields
|
||||
(e.g. `"path": "docs/report.pdf"`).
|
||||
|
||||
---
|
||||
|
||||
## EXT-004 — TURN Relay (browser mode)
|
||||
|
||||
**Status:** implemented (browser mode); pending (daemon mode)
|
||||
**Affects:** ICE server configuration only, no wire changes
|
||||
|
||||
The browser adapter reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`
|
||||
and adds a TURN server to the WebRTC `ICEServers` list. Credentials are
|
||||
generated using HMAC-SHA1 of the username (coturn `use-auth-secret` scheme).
|
||||
|
||||
YAW/2 §0 explicitly declines TURN ("No relay (TURN)"). This extension is
|
||||
opt-in via server configuration and does not affect peers that omit it.
|
||||
|
||||
---
|
||||
|
||||
## EXT-005 — Per-Network Path in FileEntry
|
||||
|
||||
**Status:** implemented
|
||||
**Affects:** `MsgFileListResp` wire message (additive field)
|
||||
|
||||
`FileEntry` gains an optional `path` field carrying the file's relative path
|
||||
within its share root (e.g. `"docs/report.pdf"`). Peers that don't understand
|
||||
this field continue to use `name` for display and download requests.
|
||||
|
||||
`MsgFileListReq` / `get` requests use `path` as the lookup key when present,
|
||||
falling back to `name` for backward compat with peers that don't send `path`.
|
||||
29
FUTURE.md
29
FUTURE.md
@@ -38,12 +38,12 @@ React + Vite frontend. Two modes:
|
||||
### NAT Traversal ✅ (WebRTC ICE/STUN)
|
||||
Solved by using WebRTC DataChannels via pion. ICE gathers host + server-reflexive (STUN) candidates and performs UDP hole punching automatically. The anchor (`cmd/anchor`) doubles as a STUN server on UDP/3478.
|
||||
|
||||
### TURN relay ✅ (shipped, browser mode)
|
||||
Browser mode now supports TURN relay. `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`, generates time-limited HMAC-SHA1 credentials (compatible with coturn `use-auth-secret`), and adds the TURN server to the ICE candidate list. Mobile/CGNAT peers that fail STUN hole-punching automatically fall back to TURN relay.
|
||||
### TURN relay ✅ (shipped)
|
||||
Both browser and daemon modes support TURN relay.
|
||||
|
||||
The peer dot in the sidebar turns yellow for relayed connections (`candidate_type: relay`).
|
||||
**Browser mode:** `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`, generates time-limited HMAC-SHA1 credentials (compatible with coturn `use-auth-secret`), and adds the TURN server to the ICE candidate list. The peer dot turns yellow for relayed connections (`candidate_type: relay`).
|
||||
|
||||
**Daemon mode:** not yet wired. Add `-turn-url` / `-turn-secret` flags to `cmd/daemon/main.go` and pass them into the `ICEServers` slice in `internal/anchor/client.go`.
|
||||
**Daemon mode:** `-turn-url` and `-turn-secret` flags on `cmd/daemon`. `turnICEServers()` in `internal/netmgr/manager.go` generates HMAC-SHA1 credentials and injects them into the ICE server list for every new peer connection.
|
||||
|
||||
### Signaling ✅ YAW/2.1 (shipped)
|
||||
Forward-secret signaling via per-session ephemeral X25519 keys. Falls back transparently to 2.0 static-key sealing for peers that don't speak 2.1.
|
||||
@@ -76,10 +76,14 @@ Multiple share roots per network, with global (all networks) or scoped visibilit
|
||||
- **Daemon:** `shares.json` next to `identity.json` in the data dir. `add_share`/`remove_share`/`list_shares` IPC commands. File listing recursively walks all share roots, returning relative paths. Backward compatible with the existing `set_share_dir` single-dir mechanism.
|
||||
- **Browser:** `waste_shares` in `localStorage` stores named share records (folder name, global flag). The `ShareManager` sidebar component shows the list with re-pick (↺) and remove (✕) buttons. Actual `File` objects live in memory — the record persists across reloads so the user can restore with one click.
|
||||
|
||||
### Additional Channels / Rooms ✅ (shipped, web UI)
|
||||
The `+` button in the Rooms sidebar section creates custom rooms, stored in `customRooms` keyed by `network_id`. Room names are slugified strings — any peer that sends to a room name causes it to appear on the recipient automatically. DM rooms (`dm:<peerId>`) appear automatically when messages arrive.
|
||||
### Additional Channels / Rooms ✅ (shipped)
|
||||
Custom rooms are supported in both the web UI and the TUI.
|
||||
|
||||
**Not yet done:** TUI room creation, daemon-side SQLite persistence of room lists across restarts.
|
||||
**Web UI:** The `+` button in the Rooms sidebar creates custom rooms, stored in `customRooms` keyed by `network_id`. Room names are slugified strings — any peer that sends to a room name causes it to appear on the recipient automatically.
|
||||
|
||||
**TUI:** Type `/room <name>` in the input to create a room. The daemon persists it in the `rooms` SQLite table and echoes a `room_created` IPC event back. On reconnect, rooms are restored via `state_snapshot`. Rooms that receive messages while not active show a `*` prefix in the sidebar; the marker clears when you switch to that room.
|
||||
|
||||
DM rooms (`dm:<peerId>`) appear automatically in both interfaces when messages arrive.
|
||||
|
||||
### File Transfer UX ✅ (shipped)
|
||||
- Manual accept/reject via the Transfers panel in the sidebar
|
||||
@@ -89,9 +93,6 @@ The `+` button in the Rooms sidebar section creates custom rooms, stored in `cus
|
||||
|
||||
**Not yet done:** resume after disconnection, daemon-side download directory.
|
||||
|
||||
### TURN Relay for Daemon Mode
|
||||
The daemon doesn't yet support TURN. Add `-turn-url` and `-turn-secret` flags to `cmd/daemon/main.go` and wire them into the ICE server list in `internal/anchor/client.go`. The credential generation is the same HMAC-SHA1 scheme already implemented in browser mode.
|
||||
|
||||
### Native UI
|
||||
Web frontend (React, already built) + Tauri shell for native packaging. The IPC protocol is the full boundary — the UI is already a pure consumer. Main work: Tauri setup, system tray, OS notifications.
|
||||
|
||||
@@ -121,8 +122,12 @@ Web frontend (React, already built) + Tauri shell for native packaging. The IPC
|
||||
| ✅ shipped | Session persistence + logout (browser mode) |
|
||||
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
|
||||
| ✅ shipped | Subfolder support + directory browser UI in file browser |
|
||||
| next | TURN relay for daemon mode |
|
||||
| next | TUI room creation + daemon-side room persistence |
|
||||
| ✅ shipped | Signed invites + invite-only networks (`RequireInvite`) |
|
||||
| ✅ shipped | Hash-based "come hang" links (`#waste:...`) |
|
||||
| ✅ shipped | Protocol extensions documented in EXTENSIONS.md |
|
||||
| ✅ shipped | TURN relay for daemon mode (`-turn-url` / `-turn-secret`) |
|
||||
| ✅ shipped | TUI room creation + daemon-side room persistence |
|
||||
| ✅ shipped | Unread room indicators in TUI (`*` prefix) |
|
||||
| next | File transfer resume after disconnection |
|
||||
| future | Native UI (React + Tauri) |
|
||||
|
||||
|
||||
24
README.md
24
README.md
@@ -169,6 +169,8 @@ The `use-auth-secret` mode generates short-lived TURN credentials from the share
|
||||
|
||||
> The browser adapter reads `turnURL` and `turnSecret` from `WASTE_CONFIG` and adds the TURN server to the WebRTC `ICEServers` list automatically. If not configured, STUN-only is used (works for most desktop/home NAT situations).
|
||||
|
||||
**Daemon mode TURN:** pass `-turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE` when starting the daemon. The same coturn `use-auth-secret` HMAC-SHA1 scheme is used — no extra config required beyond what you set up for browser mode.
|
||||
|
||||
---
|
||||
|
||||
## How it works: daemon vs browser mode
|
||||
@@ -284,6 +286,24 @@ The invite encodes the anchor URL and network name. Sharing it only lets the rec
|
||||
|
||||
Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled.
|
||||
|
||||
### Signed invites and invite-only networks (waste-go extension)
|
||||
|
||||
Invites generated by `generate_invite` are **cryptographically signed** by the generating peer. The `waste:` payload carries an `inviter` field (Ed25519 public key) and a `sig` field (signature over anchor + network + inviter). When Bob joins, the invite is forwarded in the `hello` message so Alice can verify it.
|
||||
|
||||
To enable invite-only enforcement on a network, pass `require_invite: true` in the `join_network` command. Peers presenting no invite, an unsigned invite, or an invite signed by an unknown peer are rejected.
|
||||
|
||||
### "Come hang" hang links
|
||||
|
||||
The 🔗 button in the web UI copies a **hash-based hang link**:
|
||||
|
||||
```
|
||||
https://your-domain.com/#waste:eyJ...
|
||||
```
|
||||
|
||||
The fragment (`#...`) is never sent to the server, so the network name stays server-opaque. Anyone who opens the link gets the join form pre-filled — but they still need a proper signed invite to be accepted on networks with `require_invite` enabled. Suitable for public announcements of open or semi-open networks.
|
||||
|
||||
See [EXTENSIONS.md](EXTENSIONS.md) for the full protocol addendum.
|
||||
|
||||
---
|
||||
|
||||
## Terminal UI
|
||||
@@ -300,6 +320,8 @@ go run ./cmd/tui -network friends
|
||||
|
||||
**Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit
|
||||
|
||||
**Slash commands:** `/room <name>` — create a new room (persisted in SQLite, restored on reconnect). Rooms with unread messages show a `*` prefix in the sidebar.
|
||||
|
||||
---
|
||||
|
||||
## IPC protocol
|
||||
@@ -320,6 +342,7 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
|
||||
{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // network-scoped
|
||||
{"type":"remove_share","path":"/home/alice/Music"}
|
||||
{"type":"list_shares"}
|
||||
{"type":"create_room","room":"dev"}
|
||||
{"type":"export_identity","passphrase":"..."}
|
||||
{"type":"import_identity","passphrase":"...","backup":"..."}
|
||||
```
|
||||
@@ -335,6 +358,7 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
|
||||
{"type":"invite_generated","invite":"waste:<base64>"}
|
||||
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}}
|
||||
{"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"}
|
||||
{"type":"room_created","network_id":"...","room":"dev"}
|
||||
{"type":"identity_exported","backup":"..."}
|
||||
{"type":"error","error_message":"..."}
|
||||
```
|
||||
|
||||
@@ -22,6 +22,8 @@ func main() {
|
||||
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
|
||||
shareDir := flag.String("share-dir", "", "directory to share with peers on the network")
|
||||
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
|
||||
turnURL := flag.String("turn-url", "", "TURN server URL, e.g. turn:your-vps:3478")
|
||||
turnSecret := flag.String("turn-secret", "", "shared secret for coturn use-auth-secret HMAC credential")
|
||||
importBackup := flag.String("import-identity", "", "path to a yaw-key-backup-1 JSON file to import")
|
||||
importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity")
|
||||
flag.Parse()
|
||||
@@ -74,6 +76,8 @@ func main() {
|
||||
StoreDir: dir,
|
||||
AnchorURL: *anchorURL,
|
||||
ShareDir: expandHome(*shareDir),
|
||||
TurnURL: *turnURL,
|
||||
TurnSecret: *turnSecret,
|
||||
})
|
||||
|
||||
if autoJoinNetwork != "" {
|
||||
|
||||
@@ -107,6 +107,7 @@ type model struct {
|
||||
rooms []string // "general" always first; DM rooms appended
|
||||
activeRoom int
|
||||
messages map[string][]entry
|
||||
unread map[string]bool // rooms with messages since last viewed
|
||||
|
||||
peers map[proto.PeerID]string // connected peers: id → alias
|
||||
peerOrder []proto.PeerID
|
||||
@@ -131,6 +132,7 @@ func newModel(ipcPort int, network string) model {
|
||||
networkName: network,
|
||||
rooms: []string{"general"},
|
||||
messages: make(map[string][]entry),
|
||||
unread: make(map[string]bool),
|
||||
peers: make(map[proto.PeerID]string),
|
||||
input: ti,
|
||||
status: "connecting…",
|
||||
@@ -215,9 +217,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m, cmds = m.doSend(cmds)
|
||||
case msg.Type == tea.KeyTab:
|
||||
m.activeRoom = (m.activeRoom + 1) % len(m.rooms)
|
||||
delete(m.unread, m.activeRoomName())
|
||||
m = m.refreshViewport()
|
||||
case msg.Type == tea.KeyShiftTab:
|
||||
m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms)
|
||||
delete(m.unread, m.activeRoomName())
|
||||
m = m.refreshViewport()
|
||||
default:
|
||||
var tiCmd tea.Cmd
|
||||
@@ -251,8 +255,15 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
||||
m.peers[p.ID] = p.Alias
|
||||
m.peerOrder = append(m.peerOrder, p.ID)
|
||||
}
|
||||
for _, r := range evt.Rooms {
|
||||
m = m.addRoom(r)
|
||||
}
|
||||
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
|
||||
|
||||
case proto.EvtRoomCreated:
|
||||
m = m.addRoom(evt.Room)
|
||||
m = m.refreshViewport()
|
||||
|
||||
case proto.EvtSessionReady:
|
||||
if evt.PeerID != nil {
|
||||
pid := *evt.PeerID
|
||||
@@ -283,7 +294,7 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
||||
}
|
||||
|
||||
case proto.EvtInviteGenerated:
|
||||
m.invitePopup = evt.InviteString
|
||||
m.invitePopup = evt.InviteGenerated
|
||||
|
||||
case proto.EvtMessageReceived:
|
||||
if evt.Message != nil {
|
||||
@@ -296,6 +307,9 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
||||
}
|
||||
m.messages[msg.Room] = append(m.messages[msg.Room], e)
|
||||
m = m.addRoom(msg.Room)
|
||||
if msg.Room != m.activeRoomName() {
|
||||
m.unread[msg.Room] = true
|
||||
}
|
||||
m = m.refreshViewport()
|
||||
}
|
||||
}
|
||||
@@ -309,6 +323,14 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
||||
}
|
||||
m.input.SetValue("")
|
||||
|
||||
if strings.HasPrefix(body, "/room ") {
|
||||
name := strings.TrimSpace(strings.TrimPrefix(body, "/room "))
|
||||
if name != "" {
|
||||
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdCreateRoom, Room: name}))
|
||||
}
|
||||
return m, cmds
|
||||
}
|
||||
|
||||
room := m.rooms[m.activeRoom]
|
||||
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
|
||||
if strings.HasPrefix(room, "dm:") {
|
||||
@@ -430,7 +452,7 @@ func (m model) View() string {
|
||||
if m.errMsg != "" {
|
||||
statusLine = styleErr.Render(" ✗ " + m.errMsg)
|
||||
} else {
|
||||
hint := " tab: rooms · ctrl+i: invite · ctrl+c: quit"
|
||||
hint := " tab: rooms · /room <name>: new room · ctrl+i: invite · ctrl+c: quit"
|
||||
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
|
||||
}
|
||||
|
||||
@@ -468,7 +490,11 @@ func (m model) renderRooms(boxH int) string {
|
||||
if i == m.activeRoom {
|
||||
lines = append(lines, styleActive.Width(innerW).Render("▶ "+label))
|
||||
} else {
|
||||
lines = append(lines, styleRoom.Width(innerW).Render(" "+label))
|
||||
prefix := " "
|
||||
if m.unread[room] {
|
||||
prefix = "* "
|
||||
}
|
||||
lines = append(lines, styleRoom.Width(innerW).Render(prefix+label))
|
||||
}
|
||||
}
|
||||
for len(lines) < contentH {
|
||||
|
||||
@@ -229,7 +229,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
||||
mu.Lock()
|
||||
if sess == nil {
|
||||
// Answerer: we haven't created a session yet, do it now.
|
||||
pc, err := newPC()
|
||||
pc, err := newPC(m.ICEServers)
|
||||
if err != nil {
|
||||
mu.Unlock()
|
||||
log.Printf("anchor: new PC for answerer: %v", err)
|
||||
@@ -412,7 +412,7 @@ func dispatchSignaling(
|
||||
// startOffer creates a session, sends our ekey, waits up to ekeyTimeout for
|
||||
// the peer's ekey, then sends the offer (ephemeral or static).
|
||||
func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*peerSession, error) {
|
||||
pc, err := newPC()
|
||||
pc, err := newPC(m.ICEServers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -473,7 +473,7 @@ func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m
|
||||
|
||||
// answerOffer processes an incoming offer and returns the PeerConnection.
|
||||
func answerOffer(ctx context.Context, payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender, sess *peerSession) (*webrtc.PeerConnection, error) {
|
||||
pc, err := newPC()
|
||||
pc, err := newPC(m.ICEServers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -618,10 +618,9 @@ func hashNetName(name string) string {
|
||||
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 newPC(extra []webrtc.ICEServer) (*webrtc.PeerConnection, error) {
|
||||
servers := append([]webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, extra...)
|
||||
return webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: servers})
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
@@ -131,6 +131,9 @@ func (id *Identity) PeerInfo() proto.PeerInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// PeerIDHex satisfies the invite.Signer interface.
|
||||
func (id *Identity) PeerIDHex() string { return string(id.PeerID()) }
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
// 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly
|
||||
// to the signaling server. A yaw2 client that can parse the base64 JSON can join
|
||||
// the same network without knowing the plaintext name.
|
||||
//
|
||||
// Signed invites (waste-go extension): when `inviter` and `sig` are present,
|
||||
// the invite was issued by a known peer. Receiving peers that enforce
|
||||
// RequireInvite will reject hellos that carry no valid signed invite.
|
||||
// YAW/2-only peers ignore both fields.
|
||||
package invite
|
||||
|
||||
import (
|
||||
@@ -21,29 +26,69 @@ const prefix = "waste:"
|
||||
|
||||
// Invite holds the information needed to join a network.
|
||||
type Invite struct {
|
||||
Anchor string `json:"anchor"` // WebSocket anchor URL
|
||||
Network string `json:"network"` // plaintext network name
|
||||
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
|
||||
Anchor string `json:"anchor"` // WebSocket anchor URL
|
||||
Network string `json:"network"` // plaintext network name
|
||||
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
|
||||
Inviter string `json:"inviter,omitempty"` // hex Ed25519 pubkey of the signing peer (waste-go extension)
|
||||
Sig string `json:"sig,omitempty"` // hex Ed25519 sig over canonical payload (waste-go extension)
|
||||
}
|
||||
|
||||
// Encode returns a waste: invite string for the given anchor URL and network name.
|
||||
// IsSigned reports whether the invite carries a signature.
|
||||
func (inv Invite) IsSigned() bool { return inv.Inviter != "" && inv.Sig != "" }
|
||||
|
||||
// Signer can sign data and report its own peer ID.
|
||||
type Signer interface {
|
||||
Sign(data []byte) string
|
||||
PeerIDHex() string
|
||||
}
|
||||
|
||||
// Verifier verifies an Ed25519 signature given a hex public key.
|
||||
type Verifier func(publicKeyHex string, data []byte, sigHex string) error
|
||||
|
||||
// Encode returns an unsigned waste: invite string (backward compatible).
|
||||
func Encode(anchor, network string) (string, error) {
|
||||
return marshal(Invite{
|
||||
Anchor: anchor,
|
||||
Network: network,
|
||||
Net: NetHash(network),
|
||||
})
|
||||
}
|
||||
|
||||
// EncodeSigned returns a signed waste: invite string.
|
||||
// The signature covers: anchor + NUL + network + NUL + net + NUL + inviter.
|
||||
func EncodeSigned(anchor, network string, signer Signer) (string, error) {
|
||||
if anchor == "" {
|
||||
return "", fmt.Errorf("anchor URL is required")
|
||||
}
|
||||
if network == "" {
|
||||
return "", fmt.Errorf("network name is required")
|
||||
}
|
||||
h := sha256.Sum256([]byte("yaw2-net:" + network))
|
||||
b, err := json.Marshal(Invite{
|
||||
inviter := signer.PeerIDHex()
|
||||
net := NetHash(network)
|
||||
sig := signer.Sign(sigPayload(anchor, network, net, inviter))
|
||||
return marshal(Invite{
|
||||
Anchor: anchor,
|
||||
Network: network,
|
||||
Net: hex.EncodeToString(h[:]),
|
||||
Net: net,
|
||||
Inviter: inviter,
|
||||
Sig: sig,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Verify checks the invite signature and that the inviter is in the trusted set.
|
||||
// Unsigned invites return nil — the caller decides whether to accept them.
|
||||
func Verify(inv Invite, trusted map[string]bool, verify Verifier) error {
|
||||
if !inv.IsSigned() {
|
||||
return nil
|
||||
}
|
||||
return prefix + base64.URLEncoding.EncodeToString(b), nil
|
||||
payload := sigPayload(inv.Anchor, inv.Network, inv.Net, inv.Inviter)
|
||||
if err := verify(inv.Inviter, payload, inv.Sig); err != nil {
|
||||
return fmt.Errorf("invite signature invalid: %w", err)
|
||||
}
|
||||
if !trusted[inv.Inviter] {
|
||||
return fmt.Errorf("invite signed by unknown peer %s", inv.Inviter[:16])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode parses a waste: invite string and returns the Invite.
|
||||
@@ -66,9 +111,20 @@ func Decode(s string) (Invite, error) {
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// NetHash returns the full 64-char hex network hash for the given name
|
||||
// (SHA-256("yaw2-net:" + name)). This is the `net` field sent to the anchor.
|
||||
// NetHash returns the full 64-char hex network hash for the given name.
|
||||
func NetHash(name string) string {
|
||||
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func marshal(inv Invite) (string, error) {
|
||||
b, err := json.Marshal(inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix + base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func sigPayload(anchor, network, net, inviter string) []byte {
|
||||
return []byte(anchor + "\x00" + network + "\x00" + net + "\x00" + inviter)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
@@ -146,7 +147,6 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
||||
case cmd.NetworkName != "":
|
||||
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
|
||||
case len(cmd.NetworkHash) == 64:
|
||||
// yaw2-compatible: join by full 64-char hex hash (net field)
|
||||
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
|
||||
default:
|
||||
send(errMsg("join_network: network_name or network_hash (64 hex chars) required"))
|
||||
@@ -156,7 +156,14 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
||||
send(errMsg(fmt.Sprintf("join_network: %v", err)))
|
||||
continue
|
||||
}
|
||||
// network_joined event (with share_dir) is emitted by Manager.Join/JoinByHash.
|
||||
if n, ok := mgr.Get(netID); ok {
|
||||
if cmd.RequireInvite {
|
||||
n.Mesh.RequireInvite = true
|
||||
}
|
||||
if cmd.InviteString != "" {
|
||||
n.Mesh.InviteString = cmd.InviteString
|
||||
}
|
||||
}
|
||||
_ = netID
|
||||
|
||||
case proto.CmdLeaveNetwork:
|
||||
@@ -233,6 +240,23 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
||||
})
|
||||
}
|
||||
|
||||
case proto.CmdCreateRoom:
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
send(errMsg("create_room: not joined to any network"))
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(cmd.Room)
|
||||
if name == "" || name == "general" {
|
||||
send(errMsg("create_room: room name is required and cannot be 'general'"))
|
||||
continue
|
||||
}
|
||||
if err := n.Store.SaveRoom(name); err != nil {
|
||||
send(errMsg(fmt.Sprintf("create_room: %v", err)))
|
||||
continue
|
||||
}
|
||||
send(proto.IpcMessage{Type: proto.EvtRoomCreated, NetworkID: n.ID, Room: name})
|
||||
|
||||
case proto.CmdGetState:
|
||||
send(stateSnapshot(mgr))
|
||||
|
||||
@@ -298,15 +322,15 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
||||
send(errMsg("generate_invite: daemon was started without -anchor flag"))
|
||||
continue
|
||||
}
|
||||
inv, err := invite.Encode(mgr.AnchorURL(), n.Name)
|
||||
inv, err := invite.EncodeSigned(mgr.AnchorURL(), n.Name, n.Identity)
|
||||
if err != nil {
|
||||
send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
|
||||
continue
|
||||
}
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtInviteGenerated,
|
||||
NetworkID: n.ID,
|
||||
InviteString: inv,
|
||||
Type: proto.EvtInviteGenerated,
|
||||
NetworkID: n.ID,
|
||||
InviteGenerated: inv,
|
||||
})
|
||||
|
||||
case proto.CmdSetShareDir:
|
||||
@@ -410,6 +434,11 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
|
||||
pi := all[0].Identity.PeerInfo()
|
||||
msg.LocalPeer = &pi
|
||||
msg.ConnectedPeers = all[0].Mesh.ConnectedPeers()
|
||||
if extra, err := all[0].Store.Rooms(); err == nil {
|
||||
for _, r := range extra {
|
||||
msg.Rooms = append(msg.Rooms, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return msg
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
"github.com/waste-go/internal/store"
|
||||
)
|
||||
|
||||
// ICEServer mirrors webrtc.ICEServer so callers don't import pion directly.
|
||||
type ICEServer = webrtc.ICEServer
|
||||
|
||||
// PeerConn is a live connection to one peer.
|
||||
type PeerConn struct {
|
||||
Info proto.PeerInfo
|
||||
@@ -27,11 +30,14 @@ type PeerConn struct {
|
||||
type Mesh struct {
|
||||
Identity *crypto.Identity
|
||||
Store *store.Store // may be nil if persistence is disabled
|
||||
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
||||
DownloadDir string // directory where received files are saved
|
||||
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
||||
DownloadDir string // directory where received files are saved
|
||||
RequireInvite bool // waste-go ext: reject peers that present no valid signed invite
|
||||
InviteString string // the invite this peer used to join (sent in hello to other peers)
|
||||
// ScanFiles overrides ScanShareDir when set — allows the manager to inject
|
||||
// multi-share scanning without the mesh needing to know about shares.json.
|
||||
ScanFiles func() []proto.FileEntry
|
||||
ScanFiles func() []proto.FileEntry
|
||||
ICEServers []ICEServer // extra ICE servers (e.g. TURN); appended to the default STUN entry
|
||||
|
||||
mu sync.RWMutex
|
||||
peers map[proto.PeerID]*PeerConn
|
||||
@@ -63,6 +69,29 @@ func New(id *crypto.Identity, st *store.Store) *Mesh {
|
||||
}
|
||||
}
|
||||
|
||||
// trustedPeerIDs returns a set of peer IDs trusted on this network:
|
||||
// all currently connected peers plus all peers in the persistent store.
|
||||
func (m *Mesh) trustedPeerIDs() map[string]bool {
|
||||
trusted := map[string]bool{}
|
||||
// Own identity is always trusted.
|
||||
trusted[string(m.Identity.PeerID())] = true
|
||||
// Connected peers.
|
||||
m.mu.RLock()
|
||||
for id := range m.peers {
|
||||
trusted[string(id)] = true
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
// Previously seen peers from the store.
|
||||
if m.Store != nil {
|
||||
if known, err := m.Store.KnownPeers(); err == nil {
|
||||
for id := range known {
|
||||
trusted[string(id)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return trusted
|
||||
}
|
||||
|
||||
// ScanShareDir returns the list of files in the local share directory.
|
||||
// Returns an empty slice if ShareDir is unset or the directory is empty.
|
||||
func (m *Mesh) ScanShareDir() []proto.FileEntry {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/pion/webrtc/v3"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/invite"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
@@ -43,11 +44,12 @@ func WireDataChannel(
|
||||
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),
|
||||
Type: "hello",
|
||||
ID: string(id.PeerID()),
|
||||
Nick: id.Alias,
|
||||
Caps: []string{"chat", "file"},
|
||||
Sig: id.Sign(bindBytes),
|
||||
Invite: m.InviteString,
|
||||
}
|
||||
helloJSON, _ := json.Marshal(hello)
|
||||
if err := dc.SendText(string(helloJSON)); err != nil {
|
||||
@@ -149,6 +151,37 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
|
||||
log.Printf("peer: bad hello from %s: %v", from.Short(), err)
|
||||
return
|
||||
}
|
||||
|
||||
// Invite enforcement (waste-go extension).
|
||||
if m.RequireInvite {
|
||||
if hello.Invite == "" {
|
||||
log.Printf("peer: rejecting %s — no invite presented (RequireInvite=true)", from.Short())
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtError,
|
||||
ErrorMessage: fmt.Sprintf("peer %s rejected: no invite", from.Short()),
|
||||
})
|
||||
return
|
||||
}
|
||||
inv, err := invite.Decode(hello.Invite)
|
||||
if err != nil || !inv.IsSigned() {
|
||||
log.Printf("peer: rejecting %s — invite not signed: %v", from.Short(), err)
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtError,
|
||||
ErrorMessage: fmt.Sprintf("peer %s rejected: invite not signed", from.Short()),
|
||||
})
|
||||
return
|
||||
}
|
||||
trusted := m.trustedPeerIDs()
|
||||
if err := invite.Verify(inv, trusted, crypto.Verify); err != nil {
|
||||
log.Printf("peer: rejecting %s — %v", from.Short(), err)
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtError,
|
||||
ErrorMessage: fmt.Sprintf("peer %s rejected: %v", from.Short(), err),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update alias once we have the verified nick.
|
||||
m.mu.Lock()
|
||||
if conn, ok := m.peers[from]; ok {
|
||||
|
||||
@@ -6,13 +6,20 @@ package netmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v3"
|
||||
|
||||
"github.com/waste-go/internal/anchor"
|
||||
"github.com/waste-go/internal/crypto"
|
||||
@@ -28,6 +35,8 @@ type Config struct {
|
||||
StoreDir string // base directory for per-network SQLite files
|
||||
AnchorURL string // WebSocket anchor URL used for all networks
|
||||
ShareDir string // default share directory; overridden per network via Join or SetShareDir
|
||||
TurnURL string // optional TURN server URL, e.g. "turn:your-vps:3478"
|
||||
TurnSecret string // shared secret for coturn use-auth-secret HMAC credential
|
||||
}
|
||||
|
||||
// Network is a single joined network context.
|
||||
@@ -113,6 +122,9 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) {
|
||||
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full)
|
||||
capturedNetID := netID
|
||||
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) }
|
||||
if ice := mgr.turnICEServers(); ice != nil {
|
||||
m.ICEServers = ice
|
||||
}
|
||||
|
||||
// Forward all mesh events to the Manager's fan-out, tagging with network_id.
|
||||
meshEvents := m.Subscribe()
|
||||
@@ -198,6 +210,9 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
|
||||
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID)
|
||||
capturedNetID2 := netID
|
||||
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
|
||||
if ice := mgr.turnICEServers(); ice != nil {
|
||||
m.ICEServers = ice
|
||||
}
|
||||
|
||||
meshEvents := m.Subscribe()
|
||||
go func() {
|
||||
@@ -411,6 +426,26 @@ func (mgr *Manager) emit(msg proto.IpcMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
// turnICEServers returns TURN ICE servers if TurnURL and TurnSecret are set,
|
||||
// using coturn's use-auth-secret HMAC-SHA1 time-limited credential scheme.
|
||||
// Returns nil if TURN is not configured.
|
||||
func (mgr *Manager) turnICEServers() []webrtc.ICEServer {
|
||||
if mgr.cfg.TurnURL == "" || mgr.cfg.TurnSecret == "" {
|
||||
return nil
|
||||
}
|
||||
// Username = Unix timestamp 1 hour from now.
|
||||
expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
|
||||
mac := hmac.New(sha1.New, []byte(mgr.cfg.TurnSecret))
|
||||
mac.Write([]byte(expiry))
|
||||
credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
return []webrtc.ICEServer{{
|
||||
URLs: []string{mgr.cfg.TurnURL},
|
||||
Username: expiry,
|
||||
Credential: credential,
|
||||
CredentialType: webrtc.ICECredentialTypePassword,
|
||||
}}
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func hashNetName(name string) string {
|
||||
|
||||
@@ -139,12 +139,16 @@ type FileOffer struct {
|
||||
|
||||
// HelloMessage is the first message sent on the "yaw" DataChannel.
|
||||
// The signature binds this identity to the specific DTLS session.
|
||||
// The Invite field is a waste-go extension (§ waste-go/extensions.md):
|
||||
// when RequireInvite is enabled on a network, peers that omit or present
|
||||
// an invalid signed invite are disconnected. YAW/2-only peers ignore this field.
|
||||
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
|
||||
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
|
||||
Invite string `json:"invite,omitempty"` // waste-go ext: signed waste: invite string
|
||||
}
|
||||
|
||||
// HelloBindString returns the bytes the hello signature covers:
|
||||
@@ -232,6 +236,7 @@ const (
|
||||
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
|
||||
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
|
||||
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
|
||||
CmdCreateRoom IpcMsgType = "create_room" // field: room (name)
|
||||
|
||||
// Events (daemon → UI)
|
||||
EvtMessageReceived IpcMsgType = "message_received"
|
||||
@@ -251,6 +256,7 @@ const (
|
||||
EvtIdentityExported IpcMsgType = "identity_exported"
|
||||
EvtIdentityImported IpcMsgType = "identity_imported"
|
||||
EvtSharesList IpcMsgType = "shares_list"
|
||||
EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
|
||||
)
|
||||
|
||||
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
||||
@@ -276,9 +282,11 @@ type IpcMessage struct {
|
||||
Body string `json:"body,omitempty"`
|
||||
|
||||
// join_network / leave_network
|
||||
NetworkName string `json:"network_name,omitempty"`
|
||||
NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name
|
||||
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
|
||||
NetworkName string `json:"network_name,omitempty"`
|
||||
NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name
|
||||
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
|
||||
RequireInvite bool `json:"require_invite,omitempty"` // waste-go ext: reject peers without valid signed invite
|
||||
InviteString string `json:"invite_string,omitempty"` // waste-go ext: the invite used to join (stored in mesh)
|
||||
|
||||
// send_file / set_share_dir / file_complete path
|
||||
Path string `json:"path,omitempty"`
|
||||
@@ -301,7 +309,7 @@ type IpcMessage struct {
|
||||
// multi-network: all joined networks (additive)
|
||||
Networks []NetworkInfo `json:"networks,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
InviteString string `json:"invite,omitempty"`
|
||||
InviteGenerated string `json:"invite,omitempty"`
|
||||
Files []FileEntry `json:"files,omitempty"`
|
||||
Shares []ShareEntry `json:"shares,omitempty"`
|
||||
ShareNetworks []string `json:"networks,omitempty"` // for add_share command
|
||||
|
||||
@@ -28,6 +28,11 @@ CREATE TABLE IF NOT EXISTS peers (
|
||||
alias TEXT NOT NULL,
|
||||
last_seen DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
name TEXT PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
// Store is a local SQLite-backed message and peer store.
|
||||
@@ -119,6 +124,33 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
|
||||
return msgs, rows.Err()
|
||||
}
|
||||
|
||||
// SaveRoom persists a room name. Duplicate names are silently ignored.
|
||||
func (s *Store) SaveRoom(name string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO rooms (name, created_at) VALUES (?, ?)`,
|
||||
name, time.Now().UTC(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Rooms returns all persisted room names, ordered by creation time.
|
||||
func (s *Store) Rooms() ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT name FROM rooms ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// KnownPeers returns all peers seen since this daemon started storing data.
|
||||
func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
||||
rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`)
|
||||
|
||||
@@ -54,6 +54,17 @@ export function Sidebar() {
|
||||
const displayId = localPeer?.id ?? masterId ?? ''
|
||||
const card = displayId ? makeYawCard(displayId, displayAlias) : null
|
||||
|
||||
function copyHangLink() {
|
||||
const net = networks.find(n => n.network_id === activeNetworkId)
|
||||
if (!net) return
|
||||
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WASTE_CONFIG
|
||||
const anchor = cfg?.signalURL ?? ''
|
||||
const payload = btoa(JSON.stringify({ network: net.network_name, anchor }))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_')
|
||||
const url = `${window.location.origin}/#waste:${payload}`
|
||||
navigator.clipboard?.writeText(url)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
|
||||
logout(clearId)
|
||||
@@ -82,7 +93,12 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<span className="sidebar-label">Networks</span>
|
||||
<div className="sidebar-label-row">
|
||||
<span className="sidebar-label">Networks</span>
|
||||
{activeNetworkId && (
|
||||
<button className="sidebar-add" onClick={copyHangLink} title="Copy hang link (pre-fills join form, no invite required)">🔗</button>
|
||||
)}
|
||||
</div>
|
||||
{networks.map(n => (
|
||||
<button
|
||||
key={n.network_id}
|
||||
|
||||
@@ -14,11 +14,17 @@ interface Props {
|
||||
// ?a=<url> anchor URL hint
|
||||
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
|
||||
const p = new URLSearchParams(window.location.search)
|
||||
const inviteString = p.get('invite') ?? ''
|
||||
let inviteString = p.get('invite') ?? ''
|
||||
let network = p.get('n') ?? p.get('network') ?? ''
|
||||
let netHash = p.get('net') ?? ''
|
||||
let anchor = p.get('a') ?? p.get('anchor') ?? ''
|
||||
|
||||
// Hash-based hang link: https://host/#waste:eyJ... (opaque, not sent to server)
|
||||
const hash = window.location.hash.slice(1) // strip leading #
|
||||
if (!inviteString && hash.startsWith('waste:')) {
|
||||
inviteString = hash
|
||||
}
|
||||
|
||||
if (inviteString.startsWith('waste:')) {
|
||||
try {
|
||||
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
|
||||
|
||||
Reference in New Issue
Block a user