@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.
- package/CHANGELOG.md +1309 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +24 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- 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.
|