@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,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fleet configuration (v0.3.3).
|
|
3
|
+
*
|
|
4
|
+
* One file, committed with the project, that every machine reads. The problem it solves is concrete:
|
|
5
|
+
* without it, every machine needs its own copy of the same settings -- the allow-list, the timeout, the
|
|
6
|
+
* update schedule -- and those copies drift. The machine that drifts is the one nobody looks at until
|
|
7
|
+
* it refuses a command or installs something unexpected.
|
|
8
|
+
*
|
|
9
|
+
* ## Why the file lives in the project, not on the relay
|
|
10
|
+
*
|
|
11
|
+
* A relay-hosted config would be one place to edit, but the relay would then hold the fleet's
|
|
12
|
+
* credentials, or the config would have to be split into secret and non-secret halves. The operator
|
|
13
|
+
* ruled that out: **the relay must not hold credentials**. A file in the project needs nothing from
|
|
14
|
+
* the relay, works when the relay is unreachable, and is reviewed in the same pull request as the code
|
|
15
|
+
* it governs. The project is already shared -- that is the premise of the whole tool -- so the file
|
|
16
|
+
* travels with it.
|
|
17
|
+
*
|
|
18
|
+
* ## The layering rule, and why it is explicit
|
|
19
|
+
*
|
|
20
|
+
* lowest `.w2m.json` in the project (shared, committed)
|
|
21
|
+
* `~/.w2m/machine.json` (this machine only, optional, not committed)
|
|
22
|
+
* highest the plugin's own config object (what the host passed in)
|
|
23
|
+
*
|
|
24
|
+
* Higher layers win key by key, and `describe()` reports which layer each effective value came from.
|
|
25
|
+
* That last part is the point: a setting that is present but being overridden is the failure mode of
|
|
26
|
+
* every config system, and the answer has to be readable without experimenting.
|
|
27
|
+
*
|
|
28
|
+
* ## What may not go in the file
|
|
29
|
+
*
|
|
30
|
+
* No tokens and no credentials, ever. A key that looks like a secret is **refused**, not ignored --
|
|
31
|
+
* silently dropping a token would mean the operator believes it is in effect, and a token in a
|
|
32
|
+
* committed file is a leak that survives every later decision.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
36
|
+
import { homedir } from 'node:os';
|
|
37
|
+
import { join, resolve } from 'node:path';
|
|
38
|
+
|
|
39
|
+
/** The committed file's name, at the project root. */
|
|
40
|
+
export const SHARED_CONFIG_NAME = '.w2m.json';
|
|
41
|
+
|
|
42
|
+
/** The per-machine override, outside any repository. */
|
|
43
|
+
export const MACHINE_CONFIG_NAME = 'machine.json';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Defaults for every shared key.
|
|
47
|
+
*
|
|
48
|
+
* Kept as data so `describe()` can report a default as a default rather than as "unset", and so adding
|
|
49
|
+
* a key is one line rather than a change in three places.
|
|
50
|
+
*/
|
|
51
|
+
export const SHARED_CONFIG_DEFAULTS = Object.freeze({
|
|
52
|
+
allowedCommands: Object.freeze([]),
|
|
53
|
+
defaultTimeoutMs: 300_000,
|
|
54
|
+
maxOutputBytes: 2 * 1024 * 1024,
|
|
55
|
+
updateTimes: Object.freeze(['00:00:00', '03:00:00', '05:00:00']),
|
|
56
|
+
updateTimeZone: 'Asia/Shanghai',
|
|
57
|
+
rttStaleMs: 180_000,
|
|
58
|
+
writeScope: Object.freeze([]),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Keys that are refused outright, with the reason.
|
|
63
|
+
*
|
|
64
|
+
* A substring test on the key name catches `operator_token`, `operatorToken`, `signing_secret`,
|
|
65
|
+
* `signingSecret`, `apiKey` and friends without pretending to be an exhaustive list -- the point is
|
|
66
|
+
* that the obvious spellings cannot slip through, and an unusual one is still refused by the
|
|
67
|
+
* unknown-key check below.
|
|
68
|
+
*/
|
|
69
|
+
const FORBIDDEN_KEY_PATTERNS = [
|
|
70
|
+
{ re: /token/i, why: 'tokens must live in the host config or the environment, never in a committed file' },
|
|
71
|
+
{ re: /secret/i, why: 'secrets must live in the host config or the environment, never in a committed file' },
|
|
72
|
+
{ re: /password|passwd|credential|apikey|api_key/i, why: 'credentials must never be committed' },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Every key a shared file may contain, and how to validate its value. */
|
|
76
|
+
const KEY_VALIDATORS = {
|
|
77
|
+
allowedCommands: (v) => {
|
|
78
|
+
if (!Array.isArray(v) || v.some((e) => typeof e !== 'string')) return 'must be an array of strings (an argv prefix per entry)';
|
|
79
|
+
return null;
|
|
80
|
+
},
|
|
81
|
+
defaultTimeoutMs: (v) => (Number.isInteger(v) && v > 0 && v <= 24 * 60 * 60 * 1000 ? null : 'must be an integer between 1 and 86400000'),
|
|
82
|
+
maxOutputBytes: (v) => (Number.isInteger(v) && v > 0 ? null : 'must be a positive integer'),
|
|
83
|
+
updateTimes: (v) => {
|
|
84
|
+
if (!Array.isArray(v)) return 'must be an array of "HH:MM" or "HH:MM:SS" strings';
|
|
85
|
+
// Both forms are accepted and normalized to HH:MM:SS, because the seconds field is almost always
|
|
86
|
+
// ":00" and requiring it invites a typo that is refused for no reason. The check is on the
|
|
87
|
+
// clock's real range, not just the digit count: "99:99" is the right shape and not a time.
|
|
88
|
+
const bad = v.find((t) => {
|
|
89
|
+
if (typeof t !== 'string') return true;
|
|
90
|
+
const m = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(t);
|
|
91
|
+
if (!m) return true;
|
|
92
|
+
const [h, min, sec] = [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)];
|
|
93
|
+
return h > 23 || min > 59 || sec > 59;
|
|
94
|
+
});
|
|
95
|
+
return bad === undefined ? null : `contains ${JSON.stringify(bad)}, which is not a time of day`;
|
|
96
|
+
},
|
|
97
|
+
updateTimeZone: (v) => {
|
|
98
|
+
if (typeof v !== 'string' || v.trim() === '') return 'must be a non-empty IANA time zone name';
|
|
99
|
+
try {
|
|
100
|
+
// The only honest way to check a zone name without a table is to ask the runtime to use it.
|
|
101
|
+
new Intl.DateTimeFormat('en-US', { timeZone: v });
|
|
102
|
+
return null;
|
|
103
|
+
} catch {
|
|
104
|
+
return `is not a time zone this runtime knows: ${JSON.stringify(v)}`;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
rttStaleMs: (v) => (Number.isInteger(v) && v > 0 ? null : 'must be a positive integer'),
|
|
108
|
+
writeScope: (v) => (Array.isArray(v) && v.every((e) => typeof e === 'string') ? null : 'must be an array of strings'),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse one config document.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} text - File contents.
|
|
115
|
+
* @param {string} source - Path, for error messages.
|
|
116
|
+
* @returns {{ok: true, values: object} | {ok: false, error: string, code: string, key: string|null}}
|
|
117
|
+
*/
|
|
118
|
+
export function parseSharedConfig(text, source = SHARED_CONFIG_NAME) {
|
|
119
|
+
let doc;
|
|
120
|
+
try {
|
|
121
|
+
doc = JSON.parse(text);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// A config file that does not parse must not be treated as empty: the operator would then be
|
|
124
|
+
// running on defaults while believing their settings applied.
|
|
125
|
+
return { ok: false, code: 'W2M_CONFIG_UNPARSABLE', key: null, error: `${source} is not valid JSON: ${error.message}` };
|
|
126
|
+
}
|
|
127
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
128
|
+
return { ok: false, code: 'W2M_CONFIG_NOT_AN_OBJECT', key: null, error: `${source} must contain a JSON object` };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const values = {};
|
|
132
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
133
|
+
const forbidden = FORBIDDEN_KEY_PATTERNS.find((p) => p.re.test(key));
|
|
134
|
+
if (forbidden) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
code: 'W2M_CONFIG_SECRET_REFUSED',
|
|
138
|
+
key,
|
|
139
|
+
error:
|
|
140
|
+
`${source}: refusing key "${key}" because ${forbidden.why}. ` +
|
|
141
|
+
'A token here would be committed, and silently ignoring it would let you believe it is in effect.',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (!Object.prototype.hasOwnProperty.call(KEY_VALIDATORS, key)) {
|
|
145
|
+
// Unknown keys are refused, not ignored. A typo'd key that is silently dropped is
|
|
146
|
+
// indistinguishable from a setting that is in effect, which is the worse of the two failures.
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
code: 'W2M_CONFIG_UNKNOWN_KEY',
|
|
150
|
+
key,
|
|
151
|
+
error: `${source}: unknown key "${key}"; known keys are ${Object.keys(KEY_VALIDATORS).join(', ')}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const problem = KEY_VALIDATORS[key](value);
|
|
155
|
+
if (problem !== null) {
|
|
156
|
+
return { ok: false, code: 'W2M_CONFIG_BAD_VALUE', key, error: `${source}: "${key}" ${problem}` };
|
|
157
|
+
}
|
|
158
|
+
values[key] = value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// `updateTimes` normalizes to the same shape the scheduler expects, so a caller cannot forget.
|
|
162
|
+
if (Array.isArray(values.updateTimes)) {
|
|
163
|
+
values.updateTimes = values.updateTimes.map((t) => (t.length === 5 ? `${t}:00` : t));
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, values };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Load and merge the shared layers.
|
|
170
|
+
*
|
|
171
|
+
* Reading is deliberately tolerant of **absence** and intolerant of **breakage**: no file is the normal
|
|
172
|
+
* case and yields the defaults, while a file that exists and is wrong is a thrown error. The
|
|
173
|
+
* distinction matters because "no config" and "config I could not read" must not look the same.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} [opts] - Options.
|
|
176
|
+
* @param {string} [opts.projectDir] - Project root holding the committed file.
|
|
177
|
+
* @param {string} [opts.machineDir] - Directory holding the machine-local file; defaults to `~/.w2m`.
|
|
178
|
+
* @param {object} [opts.explicit] - The host's own config object (highest precedence).
|
|
179
|
+
* @param {(p: string) => boolean} [opts.exists] - Injected for tests.
|
|
180
|
+
* @param {(p: string) => string} [opts.read] - Injected for tests.
|
|
181
|
+
* @returns {{values: object, sources: object, files: object, warnings: string[]}}
|
|
182
|
+
*/
|
|
183
|
+
export function loadSharedConfig({
|
|
184
|
+
projectDir = process.cwd(),
|
|
185
|
+
machineDir = join(homedir(), '.w2m'),
|
|
186
|
+
explicit = {},
|
|
187
|
+
exists = existsSync,
|
|
188
|
+
read = (p) => readFileSync(p, 'utf8'),
|
|
189
|
+
} = {}) {
|
|
190
|
+
const files = {
|
|
191
|
+
shared: resolve(join(projectDir, SHARED_CONFIG_NAME)),
|
|
192
|
+
machine: resolve(join(machineDir, MACHINE_CONFIG_NAME)),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** @type {object} */
|
|
196
|
+
const values = {};
|
|
197
|
+
/** @type {Record<string, string>} */
|
|
198
|
+
const sources = {};
|
|
199
|
+
for (const [key, value] of Object.entries(SHARED_CONFIG_DEFAULTS)) {
|
|
200
|
+
values[key] = Array.isArray(value) ? [...value] : value;
|
|
201
|
+
sources[key] = 'default';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const warnings = [];
|
|
205
|
+
for (const [layer, path] of [
|
|
206
|
+
['shared', files.shared],
|
|
207
|
+
['machine', files.machine],
|
|
208
|
+
]) {
|
|
209
|
+
if (!exists(path)) continue; // absent is normal
|
|
210
|
+
const parsed = parseSharedConfig(read(path), path);
|
|
211
|
+
if (!parsed.ok) {
|
|
212
|
+
// Thrown rather than downgraded to a warning: continuing on defaults while the operator believes
|
|
213
|
+
// their settings are active is exactly the silent-wrong-answer this project refuses.
|
|
214
|
+
const error = new Error(parsed.error);
|
|
215
|
+
error.code = parsed.code;
|
|
216
|
+
error.key = parsed.key;
|
|
217
|
+
error.path = path;
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
for (const [key, value] of Object.entries(parsed.values)) {
|
|
221
|
+
values[key] = Array.isArray(value) ? [...value] : value;
|
|
222
|
+
sources[key] = layer;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The host's own config is the highest layer. Only keys the shared file knows about participate, so
|
|
227
|
+
// an unrelated host setting cannot appear to come from the shared file.
|
|
228
|
+
for (const key of Object.keys(KEY_VALIDATORS)) {
|
|
229
|
+
if (explicit[key] === undefined) continue;
|
|
230
|
+
const problem = KEY_VALIDATORS[key](explicit[key]);
|
|
231
|
+
if (problem !== null) {
|
|
232
|
+
const error = new Error(`host config: "${key}" ${problem}`);
|
|
233
|
+
error.code = 'W2M_CONFIG_BAD_VALUE';
|
|
234
|
+
error.key = key;
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
values[key] = Array.isArray(explicit[key]) ? [...explicit[key]] : explicit[key];
|
|
238
|
+
sources[key] = 'explicit';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { values, sources, files, warnings };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A human-readable account of where each effective value came from.
|
|
246
|
+
*
|
|
247
|
+
* Exists because "the setting is there but something overrides it" is the characteristic failure of
|
|
248
|
+
* layered configuration, and the answer must be readable without bisecting the layers by hand.
|
|
249
|
+
*
|
|
250
|
+
* @param {ReturnType<typeof loadSharedConfig>} loaded
|
|
251
|
+
* @returns {string}
|
|
252
|
+
*/
|
|
253
|
+
export function describeSharedConfig(loaded) {
|
|
254
|
+
const lines = ['shared config:'];
|
|
255
|
+
for (const key of Object.keys(SHARED_CONFIG_DEFAULTS)) {
|
|
256
|
+
const from = loaded.sources[key] ?? 'default';
|
|
257
|
+
const value = loaded.values[key];
|
|
258
|
+
const shown = Array.isArray(value) ? JSON.stringify(value) : String(value);
|
|
259
|
+
lines.push(` ${key} = ${shown} [${from}]`);
|
|
260
|
+
}
|
|
261
|
+
lines.push(` files: shared=${loaded.files.shared}${loaded.files.shared === loaded.files.machine ? '' : `, machine=${loaded.files.machine}`}`);
|
|
262
|
+
for (const w of loaded.warnings ?? []) lines.push(` warning: ${w}`);
|
|
263
|
+
return lines.join('\n');
|
|
264
|
+
}
|