@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,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W2M relay — Prometheus metrics (v0.3.3 "metrics" item).
|
|
3
|
+
*
|
|
4
|
+
* A machine-readable surface so a fleet can be monitored without a human reading
|
|
5
|
+
* reports. Text exposition format 0.0.4.
|
|
6
|
+
*
|
|
7
|
+
* ## The rule this module exists to get right: absent is not zero
|
|
8
|
+
*
|
|
9
|
+
* `w2m_rtt_ms` is emitted ONLY for machines that have actually reported a
|
|
10
|
+
* round-trip time. A machine that never reported has **no series at all**.
|
|
11
|
+
*
|
|
12
|
+
* That is not a stylistic choice. In Prometheus a `0` is a real measurement --
|
|
13
|
+
* "this machine answered instantaneously" -- while a missing series is what
|
|
14
|
+
* `absent()` and `unless` are for. Emitting `0` for "unknown" would make a fleet
|
|
15
|
+
* of silent machines look like a fleet of perfectly fast ones, which is the
|
|
16
|
+
* worst possible failure mode for a latency dashboard: it fails toward "all
|
|
17
|
+
* good". The same null-vs-zero distinction runs through the rest of this project
|
|
18
|
+
* (`rtt_ms: null` in `/v1/agents/{id}/status`, `avg_ms: null` in `/healthz`).
|
|
19
|
+
*
|
|
20
|
+
* ## Why some series carry a `stale` companion
|
|
21
|
+
*
|
|
22
|
+
* A machine that reported 40ms and then vanished keeps that series forever; a
|
|
23
|
+
* dashboard would read "40ms" from a machine that is gone. `w2m_rtt_stale`
|
|
24
|
+
* therefore distinguishes the two readings the same way `rtt_stale` does in the
|
|
25
|
+
* HTTP API: a genuinely slow machine is `w2m_rtt_ms > X and w2m_rtt_stale == 0`.
|
|
26
|
+
*
|
|
27
|
+
* ## Determinism
|
|
28
|
+
*
|
|
29
|
+
* `renderMetrics` never reads the wall clock: `nowMs` is a parameter. Series are
|
|
30
|
+
* sorted, so two renders of the same state are byte-identical -- which is what
|
|
31
|
+
* makes the output testable instead of merely plausible.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { AGGREGATE_STATUSES, aggregate } from './report.mjs';
|
|
35
|
+
|
|
36
|
+
/** Content type for the text exposition format. */
|
|
37
|
+
export const METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
|
|
38
|
+
|
|
39
|
+
/** Prefix for every metric this module emits. */
|
|
40
|
+
export const METRIC_PREFIX = 'w2m_';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Escape a label VALUE for the text exposition format.
|
|
44
|
+
*
|
|
45
|
+
* The format defines exactly three escapes: backslash, double quote and newline.
|
|
46
|
+
* A bare carriage return has no escape defined and would break the line
|
|
47
|
+
* structure, so it is folded into the newline escape: keeping the body parseable
|
|
48
|
+
* matters more than preserving a byte that no metric label should contain.
|
|
49
|
+
*/
|
|
50
|
+
export function escapeLabelValue(value) {
|
|
51
|
+
return String(value)
|
|
52
|
+
.replace(/\\/g, '\\\\')
|
|
53
|
+
.replace(/"/g, '\\"')
|
|
54
|
+
.replace(/\r\n|\r|\n/g, '\\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Escape HELP text (only backslash and newline are meaningful there). */
|
|
58
|
+
function escapeHelpText(value) {
|
|
59
|
+
return String(value).replace(/\\/g, '\\\\').replace(/\r\n|\r|\n/g, '\\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `name{label="escaped",...} value`, or `name value` with no labels. */
|
|
63
|
+
function sample(name, labels, value) {
|
|
64
|
+
const keys = Object.keys(labels ?? {});
|
|
65
|
+
if (keys.length === 0) return `${name} ${value}`;
|
|
66
|
+
const rendered = keys.map((k) => `${k}="${escapeLabelValue(labels[k])}"`).join(',');
|
|
67
|
+
return `${name}{${rendered}} ${value}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A `# HELP` / `# TYPE` header followed by its samples. */
|
|
71
|
+
function family(name, help, type, lines) {
|
|
72
|
+
return [`# HELP ${name} ${escapeHelpText(help)}`, `# TYPE ${name} ${type}`, ...lines];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Prometheus text format values are Go floats; keep integers integral. */
|
|
76
|
+
function formatNumber(value) {
|
|
77
|
+
if (!Number.isFinite(value)) return null; // never emit NaN/Inf by accident
|
|
78
|
+
return Number.isInteger(value) ? String(value) : String(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Count leases by state across every tracked task.
|
|
83
|
+
*
|
|
84
|
+
* Only observed states get a series. Unlike aggregate verdicts there is no
|
|
85
|
+
* frozen, exported list of lease states to iterate, and inventing one here is
|
|
86
|
+
* exactly the "hardcoded list that silently misses a new member" problem -- for
|
|
87
|
+
* a gauge, an absent series already reads as zero.
|
|
88
|
+
*/
|
|
89
|
+
function leaseCounts(state) {
|
|
90
|
+
const counts = new Map();
|
|
91
|
+
for (const task of state.tasks.values()) {
|
|
92
|
+
for (const lease of task.leases.values()) {
|
|
93
|
+
const key = String(lease.state);
|
|
94
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [...counts.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Aggregate verdict counts, one entry per `AGGREGATE_STATUSES` member.
|
|
102
|
+
*
|
|
103
|
+
* The list is IMPORTED, never restated: a future verdict added to report.mjs must
|
|
104
|
+
* appear here automatically. Hardcoding it would mean a new verdict silently has
|
|
105
|
+
* no series, and a dashboard would show "no tasks" instead of "a state nobody
|
|
106
|
+
* taught the metrics layer about".
|
|
107
|
+
*
|
|
108
|
+
* Results are grouped by task first so this stays O(results) rather than
|
|
109
|
+
* O(tasks x results) per scrape.
|
|
110
|
+
*/
|
|
111
|
+
function taskStatusCounts(state, nowMs) {
|
|
112
|
+
const byTask = new Map();
|
|
113
|
+
for (const record of state.results.values()) {
|
|
114
|
+
const taskId = record?.envelope?.task_id;
|
|
115
|
+
if (typeof taskId !== 'string') continue;
|
|
116
|
+
let bucket = byTask.get(taskId);
|
|
117
|
+
if (bucket === undefined) {
|
|
118
|
+
bucket = [];
|
|
119
|
+
byTask.set(taskId, bucket);
|
|
120
|
+
}
|
|
121
|
+
bucket.push(record);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const counts = new Map(AGGREGATE_STATUSES.map((status) => [status, 0]));
|
|
125
|
+
for (const task of state.tasks.values()) {
|
|
126
|
+
let verdict;
|
|
127
|
+
try {
|
|
128
|
+
verdict = aggregate(task, byTask.get(task.task_id) ?? [], { nowMs }).status;
|
|
129
|
+
} catch {
|
|
130
|
+
// A task that cannot be aggregated is a bug, but a metrics scrape must never
|
|
131
|
+
// be the thing that takes the relay down. It is left out of every bucket
|
|
132
|
+
// rather than being counted as a verdict it does not have.
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (counts.has(verdict)) counts.set(verdict, counts.get(verdict) + 1);
|
|
136
|
+
}
|
|
137
|
+
return counts;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Collect every metric as plain data, separately from rendering it.
|
|
142
|
+
*
|
|
143
|
+
* Exported because the interesting part of this module is the numbers, and a
|
|
144
|
+
* test that asserts on numbers is far clearer than one that greps text.
|
|
145
|
+
*
|
|
146
|
+
* @param {object} state - A `RabbitState` (or anything with the same shape).
|
|
147
|
+
* @param {{nowMs?: number}} [options] - `nowMs` defaults to `state.nowMs()`.
|
|
148
|
+
* @returns {{nowMs:number, gauges:object, taskStatus:Map<string,number>, leases:Array<[string,number]>, rtt:Array<{machine_id:string, rtt_ms:number, stale:boolean, age_ms:number|null}>}}
|
|
149
|
+
*/
|
|
150
|
+
export function collectMetrics(state, { nowMs } = {}) {
|
|
151
|
+
if (!state || typeof state !== 'object') {
|
|
152
|
+
throw new TypeError('collectMetrics(state): state is required');
|
|
153
|
+
}
|
|
154
|
+
const at = nowMs ?? (typeof state.nowMs === 'function' ? state.nowMs() : 0);
|
|
155
|
+
|
|
156
|
+
let online = 0;
|
|
157
|
+
for (const device of state.devices.values()) {
|
|
158
|
+
if (device.online === true) online += 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ONLY machines that reported. `rtt_ms === 0` is a real measurement and is kept;
|
|
162
|
+
// `null`/`undefined` means "never measured" and must produce no series at all.
|
|
163
|
+
const rtt = [];
|
|
164
|
+
for (const device of state.devices.values()) {
|
|
165
|
+
const value = device.rtt_ms;
|
|
166
|
+
if (value === null || value === undefined) continue;
|
|
167
|
+
if (!Number.isFinite(value)) continue; // defensive: state validates, but never emit NaN
|
|
168
|
+
const stale = typeof state.isRttStale === 'function'
|
|
169
|
+
? state.isRttStale(device, at) === true
|
|
170
|
+
: false;
|
|
171
|
+
const age = typeof state.rttAgeMs === 'function' ? state.rttAgeMs(device, at) : null;
|
|
172
|
+
rtt.push({ machine_id: String(device.machine_id), rtt_ms: value, stale, age_ms: age });
|
|
173
|
+
}
|
|
174
|
+
rtt.sort((a, b) => (a.machine_id < b.machine_id ? -1 : a.machine_id > b.machine_id ? 1 : 0));
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
nowMs: at,
|
|
178
|
+
gauges: {
|
|
179
|
+
devices: state.devices.size,
|
|
180
|
+
devices_online: online,
|
|
181
|
+
tasks: state.tasks.size,
|
|
182
|
+
results: state.results.size,
|
|
183
|
+
},
|
|
184
|
+
taskStatus: taskStatusCounts(state, at),
|
|
185
|
+
leases: leaseCounts(state),
|
|
186
|
+
rtt,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Render the Prometheus text exposition format.
|
|
192
|
+
*
|
|
193
|
+
* @param {object} state - A `RabbitState`.
|
|
194
|
+
* @param {{nowMs?: number}} [options] - Deterministic clock injection.
|
|
195
|
+
* @returns {string} The exposition body, ending in a single newline.
|
|
196
|
+
*/
|
|
197
|
+
export function renderMetrics(state, { nowMs } = {}) {
|
|
198
|
+
const data = collectMetrics(state, { nowMs });
|
|
199
|
+
const lines = [];
|
|
200
|
+
|
|
201
|
+
lines.push(...family(
|
|
202
|
+
`${METRIC_PREFIX}devices`,
|
|
203
|
+
'Number of devices paired with this relay.',
|
|
204
|
+
'gauge',
|
|
205
|
+
[sample(`${METRIC_PREFIX}devices`, {}, data.gauges.devices)],
|
|
206
|
+
));
|
|
207
|
+
|
|
208
|
+
lines.push(...family(
|
|
209
|
+
`${METRIC_PREFIX}devices_online`,
|
|
210
|
+
'Number of paired devices currently marked online.',
|
|
211
|
+
'gauge',
|
|
212
|
+
[sample(`${METRIC_PREFIX}devices_online`, {}, data.gauges.devices_online)],
|
|
213
|
+
));
|
|
214
|
+
|
|
215
|
+
lines.push(...family(
|
|
216
|
+
`${METRIC_PREFIX}tasks`,
|
|
217
|
+
'Number of tasks the relay is tracking in memory.',
|
|
218
|
+
'gauge',
|
|
219
|
+
[sample(`${METRIC_PREFIX}tasks`, {}, data.gauges.tasks)],
|
|
220
|
+
));
|
|
221
|
+
|
|
222
|
+
lines.push(...family(
|
|
223
|
+
`${METRIC_PREFIX}results`,
|
|
224
|
+
'Number of result envelopes recorded (per machine, dedupe key and attempt).',
|
|
225
|
+
'gauge',
|
|
226
|
+
[sample(`${METRIC_PREFIX}results`, {}, data.gauges.results)],
|
|
227
|
+
));
|
|
228
|
+
|
|
229
|
+
// One series per AGGREGATE_STATUSES entry, INCLUDING zeros: a verdict that no
|
|
230
|
+
// task currently holds still exists as a concept, and a dashboard that plots a
|
|
231
|
+
// fixed set of statuses should not see series appear and vanish.
|
|
232
|
+
//
|
|
233
|
+
// TYPE is `gauge`, not `counter`, on purpose. These are current verdict counts:
|
|
234
|
+
// a task moving pending -> consistent decrements one and increments another.
|
|
235
|
+
// A counter that can go down makes `rate()` meaningless and breaks the
|
|
236
|
+
// "counter resets to 0 on restart" contract Prometheus relies on.
|
|
237
|
+
lines.push(...family(
|
|
238
|
+
`${METRIC_PREFIX}task_status_total`,
|
|
239
|
+
'Number of tracked tasks whose current aggregate verdict is this status.',
|
|
240
|
+
'gauge',
|
|
241
|
+
AGGREGATE_STATUSES.map((status) => sample(
|
|
242
|
+
`${METRIC_PREFIX}task_status_total`,
|
|
243
|
+
{ status },
|
|
244
|
+
data.taskStatus.get(status) ?? 0,
|
|
245
|
+
)),
|
|
246
|
+
));
|
|
247
|
+
|
|
248
|
+
lines.push(...family(
|
|
249
|
+
`${METRIC_PREFIX}leases`,
|
|
250
|
+
'Number of task leases in each lease state.',
|
|
251
|
+
'gauge',
|
|
252
|
+
data.leases.map(([leaseState, count]) => sample(`${METRIC_PREFIX}leases`, { state: leaseState }, count)),
|
|
253
|
+
));
|
|
254
|
+
|
|
255
|
+
// The null-vs-zero contract, in the exposition format.
|
|
256
|
+
const rttLines = [];
|
|
257
|
+
for (const entry of data.rtt) {
|
|
258
|
+
const formatted = formatNumber(entry.rtt_ms);
|
|
259
|
+
if (formatted === null) continue;
|
|
260
|
+
rttLines.push(sample(`${METRIC_PREFIX}rtt_ms`, { machine_id: entry.machine_id }, formatted));
|
|
261
|
+
}
|
|
262
|
+
lines.push(...family(
|
|
263
|
+
`${METRIC_PREFIX}rtt_ms`,
|
|
264
|
+
'Last reported round-trip time to this machine, in milliseconds. '
|
|
265
|
+
+ 'A machine that has never reported one has NO series here: absent, not 0.',
|
|
266
|
+
'gauge',
|
|
267
|
+
rttLines,
|
|
268
|
+
));
|
|
269
|
+
|
|
270
|
+
// Companion to the above: 1 when the stored measurement is older than the
|
|
271
|
+
// staleness window. Emitted only alongside an actual measurement, so a machine
|
|
272
|
+
// that never reported stays absent here too.
|
|
273
|
+
lines.push(...family(
|
|
274
|
+
`${METRIC_PREFIX}rtt_stale`,
|
|
275
|
+
'1 when this machine\'s last RTT measurement is older than the staleness window, else 0. '
|
|
276
|
+
+ 'Only emitted for machines that have reported an RTT.',
|
|
277
|
+
'gauge',
|
|
278
|
+
data.rtt.map((entry) => sample(`${METRIC_PREFIX}rtt_stale`, { machine_id: entry.machine_id }, entry.stale ? 1 : 0)),
|
|
279
|
+
));
|
|
280
|
+
|
|
281
|
+
return `${lines.join('\n')}\n`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export default renderMetrics;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer candidate registry — the signalling half of the P2P transport.
|
|
3
|
+
*
|
|
4
|
+
* Hole punching needs each side to know the other's reflexive address, and neither
|
|
5
|
+
* can learn it alone. The relay is already the one place every paired machine
|
|
6
|
+
* talks to, so it is the natural rendezvous: a machine announces the addresses it
|
|
7
|
+
* believes it is reachable at, and a peer reads them back.
|
|
8
|
+
*
|
|
9
|
+
* Design decisions worth stating:
|
|
10
|
+
*
|
|
11
|
+
* * **Ephemeral, and deliberately not persisted.** A candidate is a NAT mapping,
|
|
12
|
+
* and a NAT mapping is gone the moment the process that created it stops sending.
|
|
13
|
+
* Writing these to `devices.json` would survive a restart and then hand out
|
|
14
|
+
* addresses that cannot work -- a stale candidate is worse than no candidate,
|
|
15
|
+
* because it turns "nobody has announced yet" into a punch that fails for a
|
|
16
|
+
* reason the operator cannot see.
|
|
17
|
+
* * **A machine may only announce for itself.** The route uses the authenticated
|
|
18
|
+
* device's `machine_id` and ignores any `machine_id` in the body. Otherwise any
|
|
19
|
+
* paired machine could redirect a peer's punch at a third party.
|
|
20
|
+
* * **Bounded.** Candidate count, address shape and TTL are all validated, because
|
|
21
|
+
* this is the one place where a peer supplies an address another peer will send
|
|
22
|
+
* UDP datagrams to.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Most candidates a single machine may announce. Enough for several interfaces, not for a scan. */
|
|
26
|
+
export const MAX_CANDIDATES = 16;
|
|
27
|
+
|
|
28
|
+
/** Default lifetime of an announcement. NAT mappings are kept alive by traffic; this is the backstop. */
|
|
29
|
+
export const DEFAULT_TTL_MS = 60_000;
|
|
30
|
+
|
|
31
|
+
/** Upper bound a caller may request, so an announcement cannot be pinned indefinitely. */
|
|
32
|
+
export const MAX_TTL_MS = 10 * 60_000;
|
|
33
|
+
|
|
34
|
+
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {unknown} value
|
|
38
|
+
* @returns {boolean} Whether `value` is a syntactically valid dotted-quad IPv4.
|
|
39
|
+
*/
|
|
40
|
+
export function isIpv4(value) {
|
|
41
|
+
if (typeof value !== 'string') return false;
|
|
42
|
+
const match = IPV4.exec(value);
|
|
43
|
+
if (!match) return false;
|
|
44
|
+
for (let i = 1; i <= 4; i += 1) {
|
|
45
|
+
const octet = Number(match[i]);
|
|
46
|
+
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return false;
|
|
47
|
+
// Leading zeros are accepted by some parsers and not others; refuse them rather
|
|
48
|
+
// than let two ends disagree about which address a string means.
|
|
49
|
+
if (match[i].length > 1 && match[i].startsWith('0')) return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class PeerRegistry {
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} [options]
|
|
57
|
+
* @param {() => number} [options.nowMs]
|
|
58
|
+
* @param {number} [options.defaultTtlMs]
|
|
59
|
+
*/
|
|
60
|
+
constructor(options = {}) {
|
|
61
|
+
this.nowMs = options.nowMs ?? (() => Date.now());
|
|
62
|
+
this.defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS;
|
|
63
|
+
/** @type {Map<string, {machine_id:string, candidates:Array<{address:string,port:number}>,
|
|
64
|
+
* nat:{mapping:string|null}, announced_at_ms:number, expires_at_ms:number}>} */
|
|
65
|
+
this.entries = new Map();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Record (or replace) a machine's candidates.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} machineId Authenticated device id. Never taken from the body.
|
|
72
|
+
* @param {object} body
|
|
73
|
+
* @returns {{machine_id:string, candidates:Array<object>, expires_at:string, ttl_ms:number}}
|
|
74
|
+
*/
|
|
75
|
+
announce(machineId, body = {}) {
|
|
76
|
+
if (typeof machineId !== 'string' || machineId === '') {
|
|
77
|
+
throw new TypeError('announce: machineId is required');
|
|
78
|
+
}
|
|
79
|
+
const raw = Array.isArray(body.candidates) ? body.candidates : [];
|
|
80
|
+
if (raw.length === 0) {
|
|
81
|
+
throw Object.assign(new Error('candidates must be a non-empty array'), { code: 'BAD_CANDIDATES' });
|
|
82
|
+
}
|
|
83
|
+
if (raw.length > MAX_CANDIDATES) {
|
|
84
|
+
throw Object.assign(
|
|
85
|
+
new Error(`too many candidates: ${raw.length} > ${MAX_CANDIDATES}`),
|
|
86
|
+
{ code: 'TOO_MANY_CANDIDATES' },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const candidates = [];
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
for (const candidate of raw) {
|
|
93
|
+
const address = candidate?.address;
|
|
94
|
+
const port = candidate?.port;
|
|
95
|
+
if (!isIpv4(address)) {
|
|
96
|
+
throw Object.assign(new Error(`candidate address is not IPv4: ${JSON.stringify(address)}`), {
|
|
97
|
+
code: 'BAD_CANDIDATE_ADDRESS',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
101
|
+
throw Object.assign(new Error(`candidate port out of range: ${JSON.stringify(port)}`), {
|
|
102
|
+
code: 'BAD_CANDIDATE_PORT',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const key = `${address}:${port}`;
|
|
106
|
+
if (seen.has(key)) continue; // a duplicate is not an error, just noise
|
|
107
|
+
seen.add(key);
|
|
108
|
+
candidates.push({ address, port });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const requestedTtl = Number.isInteger(body.ttl_ms) ? body.ttl_ms : this.defaultTtlMs;
|
|
112
|
+
const ttlMs = Math.max(1000, Math.min(requestedTtl, MAX_TTL_MS));
|
|
113
|
+
const now = this.nowMs();
|
|
114
|
+
|
|
115
|
+
const entry = {
|
|
116
|
+
machine_id: machineId,
|
|
117
|
+
candidates,
|
|
118
|
+
nat: { mapping: typeof body.nat?.mapping === 'string' ? body.nat.mapping : null },
|
|
119
|
+
announced_at_ms: now,
|
|
120
|
+
expires_at_ms: now + ttlMs,
|
|
121
|
+
};
|
|
122
|
+
this.entries.set(machineId, entry);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
machine_id: machineId,
|
|
126
|
+
candidates,
|
|
127
|
+
expires_at: new Date(entry.expires_at_ms).toISOString(),
|
|
128
|
+
ttl_ms: ttlMs,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read a machine's candidates, or `null` when it never announced or the
|
|
134
|
+
* announcement has expired. Expiry is checked on read as well as on sweep, so a
|
|
135
|
+
* stale entry can never be served between sweeps.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} machineId
|
|
138
|
+
* @returns {object|null}
|
|
139
|
+
*/
|
|
140
|
+
get(machineId) {
|
|
141
|
+
const entry = this.entries.get(machineId);
|
|
142
|
+
if (!entry) return null;
|
|
143
|
+
if (this.nowMs() >= entry.expires_at_ms) {
|
|
144
|
+
this.entries.delete(machineId);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
machine_id: entry.machine_id,
|
|
149
|
+
candidates: entry.candidates.map((c) => ({ ...c })),
|
|
150
|
+
nat: { ...entry.nat },
|
|
151
|
+
announced_at: new Date(entry.announced_at_ms).toISOString(),
|
|
152
|
+
expires_at: new Date(entry.expires_at_ms).toISOString(),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Drop expired entries. @returns {number} how many were dropped */
|
|
157
|
+
sweep() {
|
|
158
|
+
const now = this.nowMs();
|
|
159
|
+
let dropped = 0;
|
|
160
|
+
for (const [machineId, entry] of this.entries) {
|
|
161
|
+
if (now >= entry.expires_at_ms) {
|
|
162
|
+
this.entries.delete(machineId);
|
|
163
|
+
dropped += 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return dropped;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Live announcements, for `/healthz` and diagnostics. */
|
|
170
|
+
stats() {
|
|
171
|
+
this.sweep();
|
|
172
|
+
return { peers_announced: this.entries.size };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
clear() {
|
|
176
|
+
this.entries.clear();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export default PeerRegistry;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W2M Rabbit relay — v0.1.2 persistence (PROTOCOL-v0.1.2 §7).
|
|
3
|
+
*
|
|
4
|
+
* `<state>/devices.json` atomic snapshot (tmp file + rename)
|
|
5
|
+
* `<state>/ledger.jsonl` append-only, one JSON object per line
|
|
6
|
+
* `<state>/operator-token.txt` single line, 0600
|
|
7
|
+
*
|
|
8
|
+
* This is the only relay module allowed to touch the filesystem.
|
|
9
|
+
* Corruption is never silent: every skipped line / unreadable snapshot is
|
|
10
|
+
* recorded in `warnings` and surfaced through `/healthz`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { rfc3339 } from './state.mjs';
|
|
17
|
+
|
|
18
|
+
export const DEVICES_FILE = 'devices.json';
|
|
19
|
+
export const LEDGER_FILE = 'ledger.jsonl';
|
|
20
|
+
export const OPERATOR_TOKEN_FILE = 'operator-token.txt';
|
|
21
|
+
|
|
22
|
+
/** Ledger entry types understood by `RabbitState#restore`. */
|
|
23
|
+
export const LEDGER_TYPES = Object.freeze([
|
|
24
|
+
'task.created',
|
|
25
|
+
'lease.changed',
|
|
26
|
+
'result.stored',
|
|
27
|
+
'task.cancelled',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export class Persistence {
|
|
31
|
+
/**
|
|
32
|
+
* @param {{dir?:string|null, now?:() => number}} options `dir: null` disables persistence.
|
|
33
|
+
*/
|
|
34
|
+
constructor({ dir = null, now = Date.now } = {}) {
|
|
35
|
+
this.dir = dir ? path.resolve(dir) : null;
|
|
36
|
+
this._now = now;
|
|
37
|
+
/** @type {{file:string, message:string, line?:number}[]} */
|
|
38
|
+
this.warnings = [];
|
|
39
|
+
this.devicesCorrupt = false;
|
|
40
|
+
this.devicesMissing = true;
|
|
41
|
+
this.revivedDevices = 0;
|
|
42
|
+
this.revivedTasks = 0;
|
|
43
|
+
this.ledgerLinesRead = 0;
|
|
44
|
+
this.ledgerLinesSkipped = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get enabled() { return this.dir !== null; }
|
|
48
|
+
get devicesFile() { return this.enabled ? path.join(this.dir, DEVICES_FILE) : null; }
|
|
49
|
+
get ledgerFile() { return this.enabled ? path.join(this.dir, LEDGER_FILE) : null; }
|
|
50
|
+
get operatorTokenFile() { return this.enabled ? path.join(this.dir, OPERATOR_TOKEN_FILE) : null; }
|
|
51
|
+
|
|
52
|
+
warn(file, message, extra = {}) {
|
|
53
|
+
this.warnings.push({ file, message, ...extra });
|
|
54
|
+
return this.warnings[this.warnings.length - 1];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
ensureDir() {
|
|
58
|
+
if (!this.enabled) return false;
|
|
59
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ---------------- devices.json ---------------- */
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @returns {{devices: Record<string, object>, corrupt: boolean, missing: boolean}}
|
|
67
|
+
* `corrupt: true` means the snapshot was unreadable — the caller starts from an
|
|
68
|
+
* EMPTY table and must report that loudly (never a silent wipe).
|
|
69
|
+
*/
|
|
70
|
+
loadDevices() {
|
|
71
|
+
if (!this.enabled) return { devices: {}, corrupt: false, missing: true };
|
|
72
|
+
if (!fs.existsSync(this.devicesFile)) return { devices: {}, corrupt: false, missing: true };
|
|
73
|
+
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = fs.readFileSync(this.devicesFile, 'utf8');
|
|
77
|
+
} catch (err) {
|
|
78
|
+
this.devicesCorrupt = true;
|
|
79
|
+
this.warn(DEVICES_FILE, `unreadable (${err.message}); starting from an empty device table`);
|
|
80
|
+
return { devices: {}, corrupt: true, missing: false };
|
|
81
|
+
}
|
|
82
|
+
if (raw.trim() === '') return { devices: {}, corrupt: false, missing: false };
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(raw);
|
|
86
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
87
|
+
throw new Error('snapshot must be a JSON object keyed by machine_id');
|
|
88
|
+
}
|
|
89
|
+
return { devices: parsed, corrupt: false, missing: false };
|
|
90
|
+
} catch (err) {
|
|
91
|
+
this.devicesCorrupt = true;
|
|
92
|
+
this.warn(DEVICES_FILE, `corrupt snapshot ignored (${err.message}); starting from an empty device table`);
|
|
93
|
+
return { devices: {}, corrupt: true, missing: false };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Atomic snapshot: write a temp file, fsync, then rename over the target. */
|
|
98
|
+
saveDevices(devices) {
|
|
99
|
+
if (!this.enabled) return false;
|
|
100
|
+
this.ensureDir();
|
|
101
|
+
const out = {};
|
|
102
|
+
const iterable = devices instanceof Map ? devices.values() : Object.values(devices ?? {});
|
|
103
|
+
for (const d of iterable) {
|
|
104
|
+
out[d.machine_id] = {
|
|
105
|
+
machine_name: d.machine_name ?? d.machine_id,
|
|
106
|
+
platform: d.platform ?? {},
|
|
107
|
+
caps: d.caps ?? {},
|
|
108
|
+
user_id: d.user_id ?? null,
|
|
109
|
+
device_token: d.device_token ?? null,
|
|
110
|
+
paired_at: rfc3339(d.paired_at_ms ?? this._now()),
|
|
111
|
+
re_paired: d.re_paired === true,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const body = JSON.stringify(out, null, 2);
|
|
115
|
+
const tmp = `${this.devicesFile}.tmp-${process.pid}-${Date.now().toString(36)}`;
|
|
116
|
+
const fd = fs.openSync(tmp, 'w', 0o600);
|
|
117
|
+
try {
|
|
118
|
+
fs.writeFileSync(fd, body, 'utf8');
|
|
119
|
+
fs.fsyncSync(fd);
|
|
120
|
+
} finally {
|
|
121
|
+
fs.closeSync(fd);
|
|
122
|
+
}
|
|
123
|
+
fs.renameSync(tmp, this.devicesFile);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ---------------- ledger.jsonl ---------------- */
|
|
128
|
+
|
|
129
|
+
/** Replay-safe read: a single broken line is skipped and counted, never fatal. */
|
|
130
|
+
loadLedger() {
|
|
131
|
+
if (!this.enabled) return [];
|
|
132
|
+
if (!fs.existsSync(this.ledgerFile)) return [];
|
|
133
|
+
let text;
|
|
134
|
+
try {
|
|
135
|
+
text = fs.readFileSync(this.ledgerFile, 'utf8');
|
|
136
|
+
} catch (err) {
|
|
137
|
+
this.warn(LEDGER_FILE, `unreadable (${err.message}); ledger not replayed`);
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
const entries = [];
|
|
141
|
+
const lines = text.split('\n');
|
|
142
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
143
|
+
const line = lines[i];
|
|
144
|
+
if (line.trim() === '') continue;
|
|
145
|
+
this.ledgerLinesRead += 1;
|
|
146
|
+
try {
|
|
147
|
+
const entry = JSON.parse(line);
|
|
148
|
+
if (!entry || typeof entry !== 'object' || typeof entry.type !== 'string') {
|
|
149
|
+
throw new Error('entry must be an object with a string `type`');
|
|
150
|
+
}
|
|
151
|
+
entries.push(entry);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
this.ledgerLinesSkipped += 1;
|
|
154
|
+
this.warn(LEDGER_FILE, `line ${i + 1} skipped: ${err.message}`, { line: i + 1 });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return entries;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** §7: synchronous append is enough — the volume is tiny. */
|
|
161
|
+
append(entry) {
|
|
162
|
+
if (!this.enabled) return false;
|
|
163
|
+
this.ensureDir();
|
|
164
|
+
const line = `${JSON.stringify({ ts: rfc3339(this._now()), ...entry })}\n`;
|
|
165
|
+
fs.appendFileSync(this.ledgerFile, line, { encoding: 'utf8', mode: 0o600 });
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* ---------------- operator-token.txt ---------------- */
|
|
170
|
+
|
|
171
|
+
readOperatorToken() {
|
|
172
|
+
if (!this.enabled) return null;
|
|
173
|
+
try {
|
|
174
|
+
if (!fs.existsSync(this.operatorTokenFile)) return null;
|
|
175
|
+
const token = fs.readFileSync(this.operatorTokenFile, 'utf8').trim();
|
|
176
|
+
return token.length > 0 ? token : null;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
this.warn(OPERATOR_TOKEN_FILE, `unreadable (${err.message}); a new token will be generated`);
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
writeOperatorToken(token) {
|
|
184
|
+
if (!this.enabled || !token) return false;
|
|
185
|
+
this.ensureDir();
|
|
186
|
+
fs.writeFileSync(this.operatorTokenFile, `${token}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
187
|
+
try { fs.chmodSync(this.operatorTokenFile, 0o600); } catch { /* best effort on Windows */ }
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Redacted view for `/healthz` — never contains tokens. */
|
|
192
|
+
describe() {
|
|
193
|
+
return {
|
|
194
|
+
enabled: this.enabled,
|
|
195
|
+
dir: this.dir,
|
|
196
|
+
revived_devices: this.revivedDevices,
|
|
197
|
+
revived_tasks: this.revivedTasks,
|
|
198
|
+
devices_corrupt: this.devicesCorrupt,
|
|
199
|
+
devices_missing: this.devicesMissing,
|
|
200
|
+
ledger_lines_read: this.ledgerLinesRead,
|
|
201
|
+
ledger_lines_skipped: this.ledgerLinesSkipped,
|
|
202
|
+
warnings: this.warnings.map((w) => ({ ...w })),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export default Persistence;
|