@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +1309 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +24 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,207 @@
1
+ # W2M Protocol — v0.3.9: P2P hole punching
2
+
3
+ > Status: **the transport is implemented and tested; the task-path integration is
4
+ > partial.** Read §7 before relying on it. Protocol version stays **1** — v0.3.9
5
+ > adds endpoints and an out-of-band channel, and removes nothing.
6
+
7
+ ## 0. Why
8
+
9
+ v1 is a star: every task offer and every result crosses the relay. That is what
10
+ makes it work through NAT with no inbound port and no `sshd`, and it is also the
11
+ design's two real costs — the relay carries all payload traffic, and it is a single
12
+ point of failure for every byte.
13
+
14
+ v0.3.9 adds a direct path. Both machines send to each other's reflexive address at
15
+ the same time, which opens both NAT mappings, and the payload then travels
16
+ peer-to-peer. The relay keeps doing what only it can do: rendezvous, the task
17
+ ledger, and the fallback.
18
+
19
+ ## 1. Path selection
20
+
21
+ | `p2p.mode` | Behaviour |
22
+ |---|---|
23
+ | `auto` (**default**) | Discover, announce, try to punch. On success use the direct path. On failure use the relay and **say so**. |
24
+ | `direct` | Require the direct path. If the punch fails, the task fails with `P2P_UNAVAILABLE`. No silent fallback. |
25
+ | `relay` | Never attempt a direct path. Byte-for-byte the v0.3.8 behaviour. |
26
+
27
+ `auto` is the default because punching **cannot** succeed against a symmetric NAT.
28
+ That is not a theoretical caveat: measured on the authoring machine behind an iPhone
29
+ hotspot, three STUN servers reported three different mapped ports for the same
30
+ socket, i.e. endpoint-dependent mapping, so no punch from that network can work.
31
+ A default that hard-required the direct path would break every deployment on such a
32
+ network the moment it was upgraded.
33
+
34
+ ### 1.1 Path reporting is mandatory, not decorative
35
+
36
+ Every offer and every result carries the path it actually took:
37
+
38
+ ```json
39
+ { "transport": "p2p", "p2p": { "rtt_ms": 14.2, "peer": "203.0.113.7:51234" } }
40
+ { "transport": "relay", "p2p": { "reason": "P2P_PUNCH_TIMEOUT", "attempts": 8 } }
41
+ ```
42
+
43
+ A fallback that is not reported is indistinguishable from a direct path that is
44
+ slow, and the operator then debugs the wrong thing. `w2m_wait` surfaces the field
45
+ per machine.
46
+
47
+ ## 2. Discovery — STUN (RFC 5389)
48
+
49
+ Each machine, holding the same UDP socket it will later punch from:
50
+
51
+ 1. sends a Binding Request to every configured STUN server in turn;
52
+ 2. reads `XOR-MAPPED-ADDRESS` from the Binding Success Response;
53
+ 3. classifies **mapping behaviour** by comparing the mapped port across servers:
54
+
55
+ | Observation | `mapping` | Punchable |
56
+ |---|---|---|
57
+ | server reports the socket's own address and port | `none` | yes (no NAT) |
58
+ | every server agrees on one port | `endpoint-independent` | yes |
59
+ | different port per server | `endpoint-dependent` | **no** — this is a symmetric NAT |
60
+ | fewer than two answers | `unknown` | attempted anyway |
61
+
62
+ `unknown` is attempted on purpose: one unreachable STUN server must not be the
63
+ reason a direct path is never tried. The punch is the test.
64
+
65
+ **Only mapping behaviour is detected.** Filtering behaviour (address-restricted vs
66
+ port-restricted cone) would need a second host to probe from, and claiming to
67
+ detect what was not probed is the failure mode this project refuses elsewhere.
68
+ **IPv4 only** in v0.3.9.
69
+
70
+ ## 3. Signalling — two new endpoints
71
+
72
+ ### 3.1 `POST /v1/peer/announce` — device token
73
+
74
+ ```json
75
+ { "candidates": [ { "address": "203.0.113.7", "port": 51234 },
76
+ { "address": "192.168.1.20", "port": 51234 } ],
77
+ "nat": { "mapping": "endpoint-independent" },
78
+ "ttl_ms": 60000 }
79
+ ```
80
+
81
+ * The entry is keyed by the **authenticated** `device.machine_id`. A `machine_id`
82
+ in the body is ignored: otherwise any paired machine could aim a peer's punch at
83
+ a third party.
84
+ * `address` must be dotted-quad IPv4 (no leading zeros, no hostnames, no IPv6);
85
+ `port` must be 1–65535. At most **16** candidates.
86
+ * `ttl_ms` is clamped to 1000 … 600000; the default is 60000.
87
+
88
+ ### 3.2 `GET /v1/peer/{machine_id}` — device token
89
+
90
+ `200` with `{ peer: { machine_id, candidates, nat, announced_at, expires_at } }`,
91
+ or `404 NOT_FOUND` when the machine has never announced or its announcement expired.
92
+ A **named 404 rather than an empty list**: "nobody has announced" and "announced
93
+ nothing usable" require different responses from the caller, and an empty list would
94
+ send it into a punch that cannot work.
95
+
96
+ ### 3.3 Announcements are ephemeral and never persisted
97
+
98
+ A candidate *is* a NAT mapping, and the mapping dies with the process that held it.
99
+ Persisting candidates would survive a restart and then hand out addresses that cannot
100
+ work. `/healthz` reports the live count as `peers_announced`.
101
+
102
+ ## 4. Punching
103
+
104
+ Both peers send a `HELLO` to every one of the other's candidates every 250 ms until
105
+ one gets through (default timeout 5 s). The peer that receives a `HELLO` replies
106
+ `HELLO_ACK` and considers the path open — the arrival of a datagram is the entire
107
+ question the punch was asking. A peer that was never told about the initiator can
108
+ still answer, which is what lets the relay broker a punch with no extra round trip.
109
+
110
+ ### 4.1 The session id
111
+
112
+ Every frame carries a 32-bit session id, so frames from an earlier punch are ignored
113
+ rather than corrupting a new one. Two peers that each invented their own id would
114
+ each discard the other's `HELLO` and time out while both insist they were sending —
115
+ which looks exactly like a NAT problem. So:
116
+
117
+ * both peers MUST pass the same `session`, normally from `deriveSession(...)` over
118
+ inputs both already share (the task id and the two machine ids);
119
+ * a peer that has no session may pass `allowSessionAdoption: true` and adopt the
120
+ initiator's id, echoing it back;
121
+ * `punch()` **throws** if neither is given, rather than generating one and failing
122
+ later with a misleading diagnosis.
123
+
124
+ ## 5. Channel framing
125
+
126
+ ```
127
+ u8 kind
128
+ u32 session
129
+ ... body
130
+ ```
131
+
132
+ | kind | id | body |
133
+ |---|---|---|
134
+ | `HELLO` | 1 | — |
135
+ | `HELLO_ACK` | 2 | — |
136
+ | `DATA` | 3 | `u32 messageId, u32 fragmentIndex, u32 fragmentTotal, payload` |
137
+ | `ACK` | 4 | `u32 messageId, u32 fragmentIndex` (selective, per fragment) |
138
+ | `DONE` | 5 | `u32 messageId` |
139
+ | `PING` | 6 | — |
140
+ | `PONG` | 7 | — (the answer to `PING`, and never a `PING`) |
141
+ | `BYE` | 8 | — |
142
+
143
+ * Payload is at most **1160 bytes** per fragment, keeping a frame inside 1200 so it
144
+ passes a 1280-byte IPv6 minimum MTU and a 1500-byte Ethernet MTU with room for a
145
+ WireGuard tunnel.
146
+ * Every fragment is acknowledged and retransmitted (RTO 300 ms, doubling to 3 s,
147
+ at most 8 attempts, window 32). A message is delivered only when all fragments
148
+ have arrived.
149
+ * Messages sent **one at a time** arrive in order; concurrent `send()` calls may
150
+ complete in either order.
151
+ * Largest message: 8 MiB (a single stdout sample is capped at 2 MiB upstream).
152
+ * `PING` is answered with `PONG`. Answering with `PING` makes two peers bounce
153
+ datagrams between them forever, and the loop looks exactly like healthy keep-alive.
154
+
155
+ ## 6. Security
156
+
157
+ * Signalling requires a **device token**; the two new routes are covered by the
158
+ existing `v0.3.0` request-signing layer when it is enabled.
159
+ * A machine can only announce for itself (§3.1).
160
+ * Candidate addresses are validated (IPv4 shape, port range, count ceiling) because
161
+ this is the one place where a peer supplies an address that another peer will send
162
+ UDP datagrams to.
163
+ * **The P2P channel is not authenticated.** Session ids are random 32-bit values and
164
+ UDP source addresses are spoofable. Do not treat a direct path as more trustworthy
165
+ than the relay path: correlate payloads with the task they belong to, and enable
166
+ request signing if the threat model includes an on-path attacker. This is stated
167
+ rather than fixed because fixing it properly needs the `v0.3.0` signing secret to
168
+ be available on both machines, which is a separate design decision.
169
+
170
+ ## 7. What is verified, and what is not
171
+
172
+ **Verified on macOS 27.0 / arm64, Node 24.21.0:**
173
+
174
+ | Item | Evidence |
175
+ |---|---|
176
+ | STUN against real public servers | Cloudflare, Google and Nextcloud all answered; `XOR-MAPPED-ADDRESS` decoded correctly |
177
+ | NAT mapping classification | measured `endpoint-dependent` on an iPhone hotspot (three servers, three ports) |
178
+ | Wire format, including a frozen captured response | `test/p2p-stun.test.mjs` |
179
+ | Punch, fragmentation, selective ACK, retransmit of an injected loss, timeout reporting, close semantics | `test/p2p-transport.test.mjs`, real UDP sockets |
180
+ | Both signalling endpoints, validation, 404 semantics, self-only announce, non-persistence | `test/p2p-signaling.test.mjs` |
181
+
182
+ **NOT verified — and cannot be, on one machine:**
183
+
184
+ | Claim | Why |
185
+ |---|---|
186
+ | A punch across a **real** NAT | needs two hosts behind two *different* NATs. Two sockets on one host share a loopback path with no translator between them: "the punch succeeded" says nothing about NAT traversal. |
187
+ | Punching through a cone NAT | as above |
188
+ | The direct path under packet loss / reordering on a real WAN | loopback loss was injected deterministically; a real path was not exercised |
189
+ | IPv6 | not implemented |
190
+ | Filtering behaviour (restricted cone) | needs a second host to probe from |
191
+
192
+ ## 8. Integration status
193
+
194
+ Implemented and tested: STUN discovery and mapping classification, punching, the
195
+ reliable channel, and the two signalling endpoints.
196
+
197
+ **Not yet wired:** the `p2p.mode` configuration surface (its semantics are defined
198
+ in §1 and default to `auto`, but nothing reads the setting yet), the Localside
199
+ announcer, and direct-path preference in `w2m_run`. Until those land, the task path
200
+ behaves exactly as v0.3.8 and the signalling layer is live but idle — a caller that
201
+ tries `transport: "p2p"` gets the relay path, because there is no `transport` field
202
+ being produced at all.
203
+
204
+ This is stated here, in `CHANGELOG.md` and in the README rather than left for a
205
+ reader to discover from a `transport` field that never appears. The one thing this
206
+ section exists to prevent is a deployment that believes it is running over a direct
207
+ path when it is not.
@@ -0,0 +1,255 @@
1
+ # W2M Protocol — v0.4.0: the direct path, on by default
2
+
3
+ > Status: **implemented and wired into the task path.** v0.3.9 shipped the P2P transport and
4
+ > admitted in §8 that nothing read `p2p.mode` and the Localside announcer did not exist. This
5
+ > release is that admission being paid off: the announcer exists, the offer can arrive over a
6
+ > punched path, the result can return over it, and every message says which path it took.
7
+ > Protocol version stays **1** — this adds endpoints and fields and removes nothing. Read §8
8
+ > for what is measured, and §9 for what is still not.
9
+
10
+ ## 0. Why the default changed
11
+
12
+ v0.3.9 documented the intent and shipped none of it. A deployment could set `p2p.mode: auto`,
13
+ see no error, and get relay-only behaviour — the worst outcome the feature can produce,
14
+ because an operator then debugs a NAT that was never used.
15
+
16
+ v0.4.0 makes the direct path the **default connection method between machines** and makes the
17
+ relay what the design always said it was: the rendezvous, the ledger, and the fallback.
18
+
19
+ ## 1. Path selection
20
+
21
+ | `p2p.mode` | Dispatcher (the machine running `w2m_run`) | Executor (Localside) |
22
+ |---|---|---|
23
+ | `auto` (**default**) | Announce, punch to each target, push the offer over the direct channel. The relay's own offer is still emitted, so a failed punch costs latency rather than the task. | Accept an offer from **either** path. The first copy wins; a later copy of the same `task_id`+`attempt` is never executed again. |
24
+ | `direct` | Same, but a failed punch is not hidden: the executor refuses the relay copy. | **Refuse** an offer that did not arrive over the direct path: `status: refused`, `refusal_reason: P2P_UNAVAILABLE`. No silent fallback. |
25
+ | `relay` | Never dials. No UDP socket is bound at all. | v0.3.9 behaviour, byte for byte. |
26
+
27
+ Why `auto` and not `direct`: punching **cannot** succeed behind a symmetric NAT, and that is
28
+ not theoretical. Measured twice now, on two different networks, with three STUN servers
29
+ returning three different mapped ports for one socket:
30
+
31
+ * the authoring machine behind an iPhone hotspot (`endpoint-dependent`, v0.3.9);
32
+ * the reference Windows client behind China Mobile (`endpoint-dependent`, v0.4.0) — three
33
+ servers, three ports: 36028, 5855, 26713.
34
+
35
+ A default that hard-required the direct path would break every deployment on such a network
36
+ the moment it was upgraded. `direct` is there for an operator who has measured their own path
37
+ and wants a failure to be loud.
38
+
39
+ ### 1.1 The table is per machine, and it is the executor's own mode that refuses
40
+
41
+ `direct` is enforced where the refusal can be honest: the executor knows how the offer
42
+ arrived, so it is the executor that refuses a relay offer. The dispatcher's mode decides
43
+ whether it bothers to punch.
44
+
45
+ ## 2. The shared server
46
+
47
+ A machine behind NAT cannot discover its own public address, and two such machines cannot find
48
+ each other. One always-on host provides both, and its address is now the default:
49
+
50
+ | Constant | Value | Used for |
51
+ |---|---|---|
52
+ | `SHARED_SERVER.rabbitUrl` | `http://202.182.123.154:8787` | The relay, when `rabbitUrl` is not configured |
53
+ | `SHARED_SERVER.stun` | `202.182.123.154:3478` | First entry of the default STUN list |
54
+
55
+ Precedence, highest first: `rabbitUrl` in the profile patch → `W2M_RABBIT_URL` →
56
+ the shared default. Which one was used is reported by `w2m_status` as `rabbit_source`
57
+ (`config` | `env` | `shared-default`), so "it works" and "it works because someone set it"
58
+ are distinguishable.
59
+
60
+ The public STUN servers (Cloudflare, Google, Nextcloud) stay in the list after the shared one.
61
+ That is deliberate: the shared responder is one machine, and a punch that is never attempted
62
+ because that one machine is down would be a self-inflicted outage.
63
+
64
+ ## 3. Announcement — the announcer v0.3.9 did not have
65
+
66
+ Each machine, holding the same UDP socket it will punch from:
67
+
68
+ 1. binds an ephemeral UDP port (`0.0.0.0:0`);
69
+ 2. asks the STUN servers for its reflexive address and classifies mapping behaviour;
70
+ 3. announces its candidates (`POST /v1/peer/announce`, device token):
71
+ the **reflexive address first**, then its private address, capped at 8 and de-duplicated;
72
+ 4. re-announces every 20 s with a 60 s TTL, on an `unref()`ed timer, so the entry stays live
73
+ without keeping a process alive.
74
+
75
+ A STUN server that does not answer is **not** fatal: the machine announces its private
76
+ candidate, records `announce_ok: false` and the error, and a LAN punch can still work. This
77
+ is the same refusal-to-give-up that makes `unknown` punchable in v0.3.9 §2.
78
+
79
+ Announcements remain ephemeral and are still never persisted — a candidate is a NAT mapping,
80
+ and a mapping dies with the process that held it.
81
+
82
+ ## 4. The offer over the direct path
83
+
84
+ The dispatcher, after `POST /v1/task` returns `{task_id, seq, leases[]}`:
85
+
86
+ 1. reads the target's lease (`index`, `attempt`, `dedupe_key`) — the pushed offer must be
87
+ recognisably the *same* work item the relay is offering, or exactly-once becomes
88
+ at-least-twice;
89
+ 2. `GET /v1/peer/{machine_id}` for the target's live candidates;
90
+ 3. punches (`HELLO` to every candidate, 250 ms apart, 5 s default budget);
91
+ 4. on success, sends `{type: 'task.offer', ...}` — the same JSON object the relay would have
92
+ sent over SSE, with `origin_machine_id` and `p2p` added — over the channel;
93
+ 5. on failure, does nothing further. The relay's offer is already on its way.
94
+
95
+ ### 4.1 The relay holds its own offer back, briefly, or the direct path could never win
96
+
97
+ An SSE offer is a push over a connection that is **already open**; a punch costs at least one
98
+ round trip. Emitting both at the same moment therefore means the relay copy arrives first
99
+ essentially always, and `transport` reads `relay` on networks where the punch succeeded. That
100
+ was measured on loopback while writing this contract: the direct copy consistently lost.
101
+
102
+ So when — and only when — the task carries both `origin_machine_id` and a `p2p.mode` of `auto`
103
+ or `direct`, the relay defers its own offer by `p2pOfferGraceMs` (default **1200 ms**) and
104
+ cancels it if the lease has moved out of `queued` by then. No new message is needed for that
105
+ test: the executor's first act on any offer it accepts is a lease heartbeat, which is exactly
106
+ the fact that says "the direct copy arrived".
107
+
108
+ Consequences, stated because they are the cost of the feature:
109
+
110
+ * a punch that fails costs one grace period of extra latency per task, and nothing else;
111
+ * a duplicate can still arrive in both directions — the grace is a window, not a lock — so the
112
+ executor's dedupe remains load-bearing and is tested in both arrival orders;
113
+ * a v0.3.9 dispatcher (no `origin_machine_id`, no `p2p`) gets the old timing byte for byte.
114
+
115
+ **Both copies can arrive, in either order.** The executor dedupes on `task_id` + `attempt`
116
+ (+ `dedupe_key` when present) rather than on the arrival path: the second copy of a task that
117
+ is queued, running or finished is not executed again. A duplicate is not an error to report;
118
+ it is the mechanism working.
119
+
120
+ ## 5. The result, and the ledger copy that is not optional
121
+
122
+ The executor returns the result on the channel the offer arrived on, when there is one:
123
+
124
+ ```
125
+ executor dispatcher
126
+ | {type:'task.result', task_id, machine_id} ->
127
+ | <- {type:'result.ack', task_id} | (only after the dispatcher has stored it)
128
+ ```
129
+
130
+ * **What travels on that frame, and what deliberately does not.** It carries the frame type, the
131
+ task id and the machine id — *not* the envelope. `result_path` is one of the envelope's own
132
+ fields and `envelope_sha256` covers the whole envelope, so the acknowledgement has to be known
133
+ before the envelope is built; sending the envelope first and stamping `result_path` afterwards
134
+ would produce a hash that does not cover the bytes it claims to. The direct path therefore
135
+ saves the dispatcher the wait for the relay's copy, **not the bytes**: the payload still reaches
136
+ it through the ledger. This is a real narrowing of what "the payload path is direct" means, and
137
+ it is written here rather than left for a reader to infer from a frame that looks short.
138
+ * The direct copy is bounded: if the dispatcher does not ack within the timeout (2 s default), the
139
+ channel is not waited on any longer and the envelope records `result_path: 'relay'`.
140
+ * **The relay copy is always sent, from the executor, unchanged in shape and ordering.** The
141
+ relay is the ledger, the report source and the only writer allowed to store a machine's own
142
+ result. v0.4.0 does not move the ledger, and a direct path that silently replaced it would
143
+ make the aggregate less trustworthy, not more.
144
+ * The dispatcher stores what the frame told it and acks; the durable record arrives on its own
145
+ schedule. A dead dispatcher — crashed DSH, closed laptop — costs the direct copy and nothing
146
+ else: the relay copy is sent after a bounded wait, never instead of it.
147
+ * A task's channel outlives its attempt by `p2pChannelLingerMs` (30 s default) so a duplicate of a
148
+ finished attempt can still be answered on it, and is released afterwards rather than accumulating
149
+ one live session per task an agent has ever run.
150
+
151
+ ## 6. Path reporting is mandatory, not decorative
152
+
153
+ Every result carries the path it actually took:
154
+
155
+ ```json
156
+ {
157
+ "transport": "p2p",
158
+ "p2p": {
159
+ "mode": "auto",
160
+ "offer_path": "p2p",
161
+ "result_path": "p2p",
162
+ "reason": null,
163
+ "rtt_ms": 43.8,
164
+ "peer": "202.182.123.154:51234",
165
+ "session": 3141592653,
166
+ "mapping": "endpoint-dependent"
167
+ }
168
+ }
169
+ ```
170
+
171
+ ```json
172
+ {
173
+ "transport": "relay",
174
+ "p2p": { "mode": "auto", "offer_path": "relay", "result_path": "relay",
175
+ "reason": "P2P_PUNCH_TIMEOUT", "rtt_ms": null, "peer": null,
176
+ "session": null, "mapping": "endpoint-dependent" }
177
+ }
178
+ ```
179
+
180
+ * `transport` is how the **offer** arrived (`p2p` | `relay`); `result_path` is how the result
181
+ returned. They are separate because a punch can open in one direction only in principle, and
182
+ a single field would have to lie about one of them.
183
+ * `peer` and `session` describe the channel **this machine opened**. On the responding side — the
184
+ machine that accepted a punch — they are `null`: a HELLO carries a session, not an identity, and
185
+ reporting the initiator's address as a peer of the responder would be inventing a fact. `mapping`
186
+ is always this machine's own measurement.
187
+ * `reason` names the failure (`P2P_NO_CANDIDATES`, `P2P_PUNCH_TIMEOUT`, `P2P_DISABLED`,
188
+ `P2P_UNAVAILABLE`) instead of leaving "not p2p" to be interpreted, and is **absent** (not null)
189
+ when the direct path worked: a key that is present is a claim.
190
+ * The task aggregate and the report's per-machine object carry both fields; the markdown
191
+ report's table has a trailing `路径` column.
192
+ * **Neither field is comparable.** A path is not a result: two machines that ran the same
193
+ bytes and disagree only about how the offer reached them are `consistent`, and a test says
194
+ so explicitly. Adding a transport field to `COMPARABLE_FIELDS` would turn a network
195
+ difference into a false bug report, which is the exact failure the six aggregation states
196
+ exist to prevent.
197
+
198
+ ## 7. The relay, extended
199
+
200
+ Additive only:
201
+
202
+ * `POST /v1/task` accepts `origin_machine_id` (string ≤ 128 chars) and `p2p` (`{mode}` with
203
+ `mode ∈ auto|direct|relay`). Both are validated shallowly and loudly — a bad value is
204
+ `BAD_REQUEST`, names the field, and creates **no** task and emits **no** offer.
205
+ * Every emitted offer carries both fields (`null` when the caller sent nothing).
206
+ * They are stored in the task and in the `task.created` ledger entry, so a relay restart
207
+ re-emitting offers (`redeliverPendingOffers`, lease takeover) does not quietly strip the
208
+ direct-push hint.
209
+ * `GET /v1/tasks/{id}` and `GET /v1/tasks/{id}/report` expose `transport`/`p2p` per machine.
210
+ A result envelope with a malformed `transport` or `p2p` is refused rather than stored: a
211
+ ledger entry reading `transport: "satellite"` would render as a path that does not exist.
212
+
213
+ ## 8. `w2m-stun` — the responder
214
+
215
+ The shared server also answers STUN itself (RFC 5389 Binding, UDP `:3478`), so a fleet does
216
+ not depend on three third-party servers being reachable from every network it runs in. It
217
+ replies `0x0101` with `XOR-MAPPED-ADDRESS`, `MAPPED-ADDRESS` and `SOFTWARE`, echoes the
218
+ transaction id, drops anything whose magic cookie is wrong, and answers `0x0111`/400 only for
219
+ an unknown comprehension-required attribute.
220
+
221
+ Deliberately **not** implemented: RFC 5780 (`CHANGE-REQUEST` / `OTHER-ADDRESS`), so this
222
+ responder cannot classify filtering behaviour. That would need a second address, and claiming
223
+ to detect what was not probed is the failure mode this project keeps refusing.
224
+
225
+ The v0.3.9 client needed no change to talk to it — the responder was written against the same
226
+ message layout, and the test that proves it is a frozen byte-level vector, not a round trip
227
+ through the encoder it was written next to.
228
+
229
+ ## 9. What is verified, and what is not
230
+
231
+ **Verified:**
232
+
233
+ | Claim | Evidence |
234
+ |---|---|
235
+ | Announce → rendezvous → punch → offer → execute → result, over real UDP sockets | `test/p2p-node.test.mjs`, `test/p2p-agent.test.mjs` |
236
+ | The responder's wire format, including a frozen byte vector and error path | `test/stun-server.test.mjs` |
237
+ | The relay's pass-through, validation and restart durability | `test/p2p-transport-fields.test.mjs` |
238
+ | A transport difference never changes a verdict | `test/p2p-transport-fields.test.mjs`, `test/p2p-plugin.test.mjs` |
239
+ | Exactly-once under double delivery (relay + direct, both orders) | `test/p2p-agent.test.mjs` |
240
+ | One real WAN hop, with numbers: dispatcher on a Chinese residential network behind a **symmetric** NAT, executor on the shared server in Tokyo — `HELLO`→`HELLO_ACK` in **331.61 ms**, ledger `transport: "p2p"`, `result_path: "p2p"` | `_ops/wan-p2p-verify.mjs`, recorded in `RELEASE-STATUS.md` and `CHANGELOG.md` |
241
+ | The shared server's relay, STUN responder and proxy, reachable from the public internet | `deploy/shared-server/`, live instance |
242
+
243
+ **Not verified — and the release says so rather than implying otherwise:**
244
+
245
+ | Claim | Why it is still open |
246
+ |---|---|
247
+ | A punch between **two** hosts behind two **different** NATs | Needs two such networks at once. Measured here: the client network is symmetric, so a two-NAT punch from it cannot succeed — which is exactly what the fallback is for. |
248
+ | Punching through a cone NAT | Same reason. |
249
+ | The direct path under real packet loss / reordering | Loopback loss is injected deterministically; a lossy WAN path was not exercised. |
250
+ | IPv6 | Not implemented in the transport, and the responder's IPv6 branch is unexercised (no `udp6` socket was created while testing). |
251
+ | The UDP channel's authenticity | Unchanged from v0.3.9 §6: session ids are random 32-bit values and UDP source addresses are spoofable. A direct path is not more trustworthy than the relay; enable request signing when the threat model includes an on-path attacker. |
252
+
253
+ A loopback punch is **not** evidence of NAT traversal: two sockets on one host share a path
254
+ with no translator between them. The WAN measurement is one hop with one NAT, and it is
255
+ labelled as one hop with one NAT.