@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,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local device identity (§2.1).
|
|
3
|
+
*
|
|
4
|
+
* `$DSH_HOME/xclient/device.json`, mode 0600:
|
|
5
|
+
*
|
|
6
|
+
* { machine_id, machine_name, device_token, rabbit_url }
|
|
7
|
+
*
|
|
8
|
+
* `machine_id` is a UUID v4 generated once and never derived from the
|
|
9
|
+
* hostname: hostnames change, collide across subnets, and are reused by
|
|
10
|
+
* clones/VM templates, while the whole point of `machine_id` is to be a stable
|
|
11
|
+
* key for leases, dedupe and the device table.
|
|
12
|
+
*
|
|
13
|
+
* A corrupt identity file is a hard error, not something to paper over by
|
|
14
|
+
* generating a fresh id -- silently minting a new `machine_id` would make
|
|
15
|
+
* Rabbit believe a second machine appeared and orphan the existing leases.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { hostname } from 'node:os';
|
|
20
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
import { dshHome, normalizeArch, osName } from './caps.mjs';
|
|
23
|
+
|
|
24
|
+
/** Contract-mandated permission bits for `device.json`. */
|
|
25
|
+
export const DEVICE_FILE_MODE = 0o600;
|
|
26
|
+
|
|
27
|
+
/** Directory holding the identity file. */
|
|
28
|
+
export const DEVICE_DIR_NAME = 'xclient';
|
|
29
|
+
|
|
30
|
+
/** Identity file name. */
|
|
31
|
+
export const DEVICE_FILE_NAME = 'device.json';
|
|
32
|
+
|
|
33
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Absolute path of `device.json`.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} [options]
|
|
39
|
+
* @param {Record<string,string|undefined>} [options.env]
|
|
40
|
+
* @param {string} [options.dir] Directory override (tests, throwaway profiles).
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function deviceFilePath(options = {}) {
|
|
44
|
+
const env = options.env ?? process.env;
|
|
45
|
+
if (options.dir) {
|
|
46
|
+
const dir = isAbsolute(options.dir) ? options.dir : resolve(options.dir);
|
|
47
|
+
return join(dir, DEVICE_FILE_NAME);
|
|
48
|
+
}
|
|
49
|
+
const explicitFile = env.DSH_DEVICE_FILE && env.DSH_DEVICE_FILE.trim() !== ''
|
|
50
|
+
? env.DSH_DEVICE_FILE
|
|
51
|
+
: null;
|
|
52
|
+
if (explicitFile) return isAbsolute(explicitFile) ? explicitFile : resolve(explicitFile);
|
|
53
|
+
return join(dshHome(env), DEVICE_DIR_NAME, DEVICE_FILE_NAME);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fresh machine id (UUID v4).
|
|
58
|
+
*
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function newMachineId() {
|
|
62
|
+
return randomUUID();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Default `machine_name`: `<win|mac|linux>-<sanitized hostname>`.
|
|
67
|
+
*
|
|
68
|
+
* The hostname is only a *name*; it never leaks into `machine_id`.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} [options]
|
|
71
|
+
* @param {string} [options.host]
|
|
72
|
+
* @param {string} [options.platform]
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
export function defaultMachineName(options = {}) {
|
|
76
|
+
const os = osName(options.platform ?? process.platform);
|
|
77
|
+
const prefix = os === 'windows' ? 'win' : os === 'macos' ? 'mac' : os;
|
|
78
|
+
const host = String(options.host ?? hostname() ?? '')
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
81
|
+
.replace(/^-+|-+$/g, '');
|
|
82
|
+
const name = host === '' ? `${prefix}-device` : `${prefix}-${host}`;
|
|
83
|
+
return name.slice(0, 63).replace(/-+$/, '');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate a parsed identity object.
|
|
88
|
+
*
|
|
89
|
+
* @param {unknown} value
|
|
90
|
+
* @returns {{ok: true, identity: object}|{ok: false, reason: string}}
|
|
91
|
+
*/
|
|
92
|
+
export function validateIdentity(value) {
|
|
93
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
94
|
+
return { ok: false, reason: 'NOT_AN_OBJECT' };
|
|
95
|
+
}
|
|
96
|
+
const machineId = value.machine_id;
|
|
97
|
+
if (typeof machineId !== 'string' || !UUID_V4.test(machineId)) {
|
|
98
|
+
return { ok: false, reason: 'MACHINE_ID_NOT_UUID_V4' };
|
|
99
|
+
}
|
|
100
|
+
if (value.machine_name !== undefined && typeof value.machine_name !== 'string') {
|
|
101
|
+
return { ok: false, reason: 'MACHINE_NAME_NOT_STRING' };
|
|
102
|
+
}
|
|
103
|
+
if (value.device_token !== undefined && value.device_token !== null && typeof value.device_token !== 'string') {
|
|
104
|
+
return { ok: false, reason: 'DEVICE_TOKEN_NOT_STRING' };
|
|
105
|
+
}
|
|
106
|
+
if (value.rabbit_url !== undefined && value.rabbit_url !== null && typeof value.rabbit_url !== 'string') {
|
|
107
|
+
return { ok: false, reason: 'RABBIT_URL_NOT_STRING' };
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, identity: value };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read `device.json`.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} [options]
|
|
116
|
+
* @param {Record<string,string|undefined>} [options.env]
|
|
117
|
+
* @param {string} [options.dir]
|
|
118
|
+
* @returns {{exists: boolean, path: string, identity: object|null, reason: string|null}}
|
|
119
|
+
*/
|
|
120
|
+
export function loadIdentity(options = {}) {
|
|
121
|
+
const path = deviceFilePath(options);
|
|
122
|
+
let text;
|
|
123
|
+
try {
|
|
124
|
+
text = readFileSync(path, 'utf8');
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error?.code === 'ENOENT') return { exists: false, path, identity: null, reason: null };
|
|
127
|
+
return { exists: false, path, identity: null, reason: `READ_FAILED:${error?.code ?? 'UNKNOWN'}` };
|
|
128
|
+
}
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(text);
|
|
132
|
+
} catch {
|
|
133
|
+
return { exists: true, path, identity: null, reason: 'INVALID_JSON' };
|
|
134
|
+
}
|
|
135
|
+
const valid = validateIdentity(parsed);
|
|
136
|
+
if (!valid.ok) return { exists: true, path, identity: null, reason: valid.reason };
|
|
137
|
+
return { exists: true, path, identity: parsed, reason: null };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Write `device.json` atomically, with 0600 where the platform honours it.
|
|
142
|
+
*
|
|
143
|
+
* Windows/NTFS ignores POSIX mode bits; the chmod is best-effort and never
|
|
144
|
+
* fatal, which is reported honestly rather than pretended.
|
|
145
|
+
*
|
|
146
|
+
* @param {object} identity
|
|
147
|
+
* @param {object} [options]
|
|
148
|
+
* @param {Record<string,string|undefined>} [options.env]
|
|
149
|
+
* @param {string} [options.dir]
|
|
150
|
+
* @returns {string} The path written.
|
|
151
|
+
*/
|
|
152
|
+
export function saveIdentity(identity, options = {}) {
|
|
153
|
+
const valid = validateIdentity(identity);
|
|
154
|
+
if (!valid.ok) {
|
|
155
|
+
const error = new Error(`refusing to write an invalid identity: ${valid.reason}`);
|
|
156
|
+
error.code = 'IDENTITY_INVALID';
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
const path = deviceFilePath(options);
|
|
160
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
161
|
+
const payload = {
|
|
162
|
+
machine_id: identity.machine_id,
|
|
163
|
+
machine_name: identity.machine_name ?? defaultMachineName(options),
|
|
164
|
+
device_token: identity.device_token ?? null,
|
|
165
|
+
rabbit_url: identity.rabbit_url ?? null,
|
|
166
|
+
};
|
|
167
|
+
const tmp = `${path}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
168
|
+
writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: DEVICE_FILE_MODE });
|
|
169
|
+
renameSync(tmp, path);
|
|
170
|
+
try {
|
|
171
|
+
chmodSync(path, DEVICE_FILE_MODE);
|
|
172
|
+
} catch {
|
|
173
|
+
/* NTFS does not carry POSIX bits */
|
|
174
|
+
}
|
|
175
|
+
return path;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Load the identity, creating one on first run.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} [options]
|
|
182
|
+
* @param {Record<string,string|undefined>} [options.env]
|
|
183
|
+
* @param {string} [options.dir]
|
|
184
|
+
* @param {string} [options.name] `--name` override.
|
|
185
|
+
* @param {string} [options.rabbitUrl]
|
|
186
|
+
* @returns {{identity: object, path: string, created: boolean}}
|
|
187
|
+
*/
|
|
188
|
+
export function loadOrCreateIdentity(options = {}) {
|
|
189
|
+
const loaded = loadIdentity(options);
|
|
190
|
+
if (loaded.exists && !loaded.identity) {
|
|
191
|
+
const error = new Error(
|
|
192
|
+
`identity file ${loaded.path} is unusable (${loaded.reason}); refusing to mint a new machine_id`,
|
|
193
|
+
);
|
|
194
|
+
error.code = 'IDENTITY_CORRUPT';
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
if (loaded.identity) {
|
|
198
|
+
let changed = false;
|
|
199
|
+
const identity = { ...loaded.identity };
|
|
200
|
+
if (options.name && options.name !== identity.machine_name) {
|
|
201
|
+
identity.machine_name = options.name;
|
|
202
|
+
changed = true;
|
|
203
|
+
}
|
|
204
|
+
if (options.rabbitUrl && options.rabbitUrl !== identity.rabbit_url) {
|
|
205
|
+
identity.rabbit_url = options.rabbitUrl;
|
|
206
|
+
changed = true;
|
|
207
|
+
}
|
|
208
|
+
if (changed) saveIdentity(identity, options);
|
|
209
|
+
return { identity, path: loaded.path, created: false };
|
|
210
|
+
}
|
|
211
|
+
const identity = {
|
|
212
|
+
machine_id: newMachineId(),
|
|
213
|
+
machine_name: options.name ?? defaultMachineName(options),
|
|
214
|
+
device_token: null,
|
|
215
|
+
rabbit_url: options.rabbitUrl ?? null,
|
|
216
|
+
};
|
|
217
|
+
const path = saveIdentity(identity, options);
|
|
218
|
+
return { identity, path, created: true };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Persist a freshly paired `device_token`.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} token
|
|
225
|
+
* @param {object} [options]
|
|
226
|
+
* @returns {object} The updated identity.
|
|
227
|
+
*/
|
|
228
|
+
export function saveDeviceToken(token, options = {}) {
|
|
229
|
+
if (typeof token !== 'string' || token.trim() === '') {
|
|
230
|
+
const error = new Error('refusing to store an empty device_token');
|
|
231
|
+
error.code = 'DEVICE_TOKEN_INVALID';
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
const { identity } = loadOrCreateIdentity(options);
|
|
235
|
+
const updated = { ...identity, device_token: token };
|
|
236
|
+
saveIdentity(updated, options);
|
|
237
|
+
return updated;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Convenience: identity + a `platform`-ish summary for the pair request.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} [options]
|
|
244
|
+
* @returns {{machine_id: string, machine_name: string, platform: {os: string, arch: string}}}
|
|
245
|
+
*/
|
|
246
|
+
export function describeMachine(options = {}) {
|
|
247
|
+
const { identity } = loadOrCreateIdentity(options);
|
|
248
|
+
return {
|
|
249
|
+
machine_id: identity.machine_id,
|
|
250
|
+
machine_name: identity.machine_name,
|
|
251
|
+
platform: { os: osName(process.platform), arch: normalizeArch(process.arch) },
|
|
252
|
+
};
|
|
253
|
+
}
|