arisa 5.1.8 → 5.1.10
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/package.json +1 -1
- package/src/runtime/slave-cli.js +18 -5
- package/src/runtime/slave-service.js +18 -4
- package/test/slave-cli.test.js +32 -0
package/package.json
CHANGED
package/src/runtime/slave-cli.js
CHANGED
|
@@ -165,6 +165,14 @@ export function formatSlaveStatus({ systemd, diagnostic }) {
|
|
|
165
165
|
].join("\n");
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
function explainSlaveBootstrapError(error) {
|
|
169
|
+
if (error?.message !== "Socket closed before the protocol completed") return error;
|
|
170
|
+
return new Error(
|
|
171
|
+
"Master ended the pairing handshake before it completed. The bootstrap URL may be expired, rotated, already used, or invalid. Generate a new bootstrap URL on Master and retry before it expires.",
|
|
172
|
+
{ cause: error }
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
168
176
|
export async function runSlaveBootstrap(url, {
|
|
169
177
|
paths = getSlavePaths(resolveSlaveHome()),
|
|
170
178
|
ask = askFromTerminal,
|
|
@@ -181,11 +189,16 @@ export async function runSlaveBootstrap(url, {
|
|
|
181
189
|
const account = await selectAccount({ ask });
|
|
182
190
|
await ensureSlaveConfig(paths);
|
|
183
191
|
await ensureTool(paths);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
192
|
+
let result;
|
|
193
|
+
try {
|
|
194
|
+
result = await withSecureRequestFile({
|
|
195
|
+
directory: paths.tmpDir,
|
|
196
|
+
prefix: "bootstrap",
|
|
197
|
+
value: { url }
|
|
198
|
+
}, (bootstrapFile) => invokeTool(paths, { action: "slave.bootstrap", bootstrapFile }));
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw explainSlaveBootstrapError(error);
|
|
201
|
+
}
|
|
189
202
|
await installService({ account, slaveHome: paths.home, entryFile });
|
|
190
203
|
await writeSlaveServiceDescriptor(paths, { version: 1, account, installedAt: new Date().toISOString() });
|
|
191
204
|
output.log(`Arisa Slave paired and running as ${account.user}${account.root ? " (root)" : ""}.`);
|
|
@@ -65,7 +65,21 @@ export async function selectSlaveServiceAccount({
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function quoteSystemd(value) {
|
|
68
|
-
return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
68
|
+
return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function escapeSystemdPath(value) {
|
|
72
|
+
const text = String(value);
|
|
73
|
+
if (text.includes("\0")) throw new Error("Systemd paths cannot contain NUL bytes");
|
|
74
|
+
return [...text].map((character) => {
|
|
75
|
+
if (character === "\\") return "\\\\";
|
|
76
|
+
if (character === "%") return "%%";
|
|
77
|
+
const codePoint = character.codePointAt(0);
|
|
78
|
+
if (codePoint <= 0x20 || codePoint === 0x7f || character === '"' || character === "'") {
|
|
79
|
+
return `\\x${codePoint.toString(16).padStart(2, "0")}`;
|
|
80
|
+
}
|
|
81
|
+
return character;
|
|
82
|
+
}).join("");
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
export function buildSlaveSystemdUnit({ account, slaveHome, entryFile, nodePath = process.execPath }) {
|
|
@@ -84,10 +98,10 @@ export function buildSlaveSystemdUnit({ account, slaveHome, entryFile, nodePath
|
|
|
84
98
|
userDirective.trimEnd(),
|
|
85
99
|
`Environment=${quoteSystemd(`ARISA_HOME=${paths.home}`)}`,
|
|
86
100
|
`Environment=${quoteSystemd(`ARISA_SLAVE_HOME=${paths.home}`)}`,
|
|
87
|
-
`WorkingDirectory=${
|
|
101
|
+
`WorkingDirectory=${escapeSystemdPath(paths.home)}`,
|
|
88
102
|
`ExecStart=${quoteSystemd(nodePath)} ${quoteSystemd(entryFile)} slave --service-runner`,
|
|
89
|
-
`StandardOutput=append:${paths.logFile}`,
|
|
90
|
-
`StandardError=append:${paths.logFile}`,
|
|
103
|
+
`StandardOutput=append:${escapeSystemdPath(paths.logFile)}`,
|
|
104
|
+
`StandardError=append:${escapeSystemdPath(paths.logFile)}`,
|
|
91
105
|
"Restart=on-failure",
|
|
92
106
|
"RestartSec=2",
|
|
93
107
|
"",
|
package/test/slave-cli.test.js
CHANGED
|
@@ -82,11 +82,23 @@ test("builds a dedicated headless systemd service with isolated state", () => {
|
|
|
82
82
|
});
|
|
83
83
|
assert.match(unit, /User=arisa-slave/);
|
|
84
84
|
assert.match(unit, /Environment="ARISA_HOME=\/var\/lib\/arisa-slave"/);
|
|
85
|
+
assert.match(unit, /^WorkingDirectory=\/var\/lib\/arisa-slave$/m);
|
|
85
86
|
assert.match(unit, /ExecStart="\/usr\/bin\/node" "\/opt\/arisa\/src\/index\.js" slave --service-runner/);
|
|
86
87
|
assert.match(unit, /StandardOutput=append:\/var\/lib\/arisa-slave\/state\/arisa-slave\.log/);
|
|
87
88
|
assert.doesNotMatch(unit, /Telegram|Pi Agent/);
|
|
88
89
|
});
|
|
89
90
|
|
|
91
|
+
test("escapes systemd WorkingDirectory paths without quoting the entire value", () => {
|
|
92
|
+
const unit = buildSlaveSystemdUnit({
|
|
93
|
+
account: { scope: "user", user: "tester" },
|
|
94
|
+
slaveHome: "/srv/arisa slave",
|
|
95
|
+
entryFile: "/opt/arisa/src/index.js",
|
|
96
|
+
nodePath: "/usr/bin/node"
|
|
97
|
+
});
|
|
98
|
+
assert.match(unit, /^WorkingDirectory=\/srv\/arisa\\x20slave$/m);
|
|
99
|
+
assert.match(unit, /^StandardOutput=append:\/srv\/arisa\\x20slave\/state\/arisa-slave\.log$/m);
|
|
100
|
+
});
|
|
101
|
+
|
|
90
102
|
test("refuses to replace the PID of an active Slave host", async (t) => {
|
|
91
103
|
const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-pid-"));
|
|
92
104
|
t.after(() => rm(home, { recursive: true, force: true }));
|
|
@@ -164,6 +176,26 @@ test("validates before effects and keeps the bootstrap secret out of service met
|
|
|
164
176
|
assert.deepEqual(calls, []);
|
|
165
177
|
});
|
|
166
178
|
|
|
179
|
+
test("explains an incomplete Master pairing handshake", async (t) => {
|
|
180
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-handshake-"));
|
|
181
|
+
t.after(() => rm(home, { recursive: true, force: true }));
|
|
182
|
+
await assert.rejects(
|
|
183
|
+
() => runSlaveBootstrap(`tcp://198.51.100.12:4719/${secret}`, {
|
|
184
|
+
paths: getSlavePaths(home),
|
|
185
|
+
entryFile: "/opt/arisa/src/index.js",
|
|
186
|
+
platform: "linux",
|
|
187
|
+
selectAccount: async () => ({ scope: "user", user: "tester", root: false, dedicated: false }),
|
|
188
|
+
ensureTool: async () => {},
|
|
189
|
+
installService: async () => assert.fail("service must not be installed after a failed handshake"),
|
|
190
|
+
invokeTool: async () => {
|
|
191
|
+
throw new Error("Socket closed before the protocol completed");
|
|
192
|
+
},
|
|
193
|
+
output: { log: () => {} }
|
|
194
|
+
}),
|
|
195
|
+
/bootstrap URL may be expired, rotated, already used, or invalid.*before it expires/i
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
167
199
|
test("ships an immutable verified master-slave bootstrap lock", async (t) => {
|
|
168
200
|
const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-lock-"));
|
|
169
201
|
t.after(() => rm(home, { recursive: true, force: true }));
|