herdr-remote 0.2.7 → 0.2.9
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/LICENSE +1 -1
- package/README.md +12 -0
- package/README.zh-CN.md +10 -0
- package/dist/tui.mjs +141 -15
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/config.js +4 -1
- package/src/herdr-command.js +151 -4
- package/src/herdr-plugin.js +6 -2
- package/src/host-connector.js +96 -3
- package/src/i18n/en.js +1 -1
- package/src/i18n/zh.js +1 -1
- package/src/keepalive.js +60 -4
- package/src/pasted-files.js +176 -0
- package/src/service.js +7 -1
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -45,6 +45,18 @@ herdr-remote plugin link
|
|
|
45
45
|
- Settings: `~/.config/herdr-remote/config.json`
|
|
46
46
|
- Runtime state: `~/.local/state/herdr-remote/runtime.json` (mode `0600`)
|
|
47
47
|
|
|
48
|
+
## Locating Herdr
|
|
49
|
+
|
|
50
|
+
The `herdr` binary is looked up in `HERDR_BIN_PATH`, then `PATH`, then the usual
|
|
51
|
+
install directories (`~/.local/bin`, `~/.cargo/bin`, `~/bin`, `/opt/homebrew/bin`,
|
|
52
|
+
`/usr/local/bin`, …). `herdr-remote keepalive install` writes the binary it found
|
|
53
|
+
and the current `PATH` into the systemd unit or launchd agent, because a service
|
|
54
|
+
manager does not inherit your shell's environment.
|
|
55
|
+
|
|
56
|
+
`herdr-remote status --json` reports `host.herdrCommand` and `host.herdrCommandFound`.
|
|
57
|
+
If it was not found, set `HERDR_BIN_PATH` to the full path and reinstall the
|
|
58
|
+
keep-alive service.
|
|
59
|
+
|
|
48
60
|
## License
|
|
49
61
|
|
|
50
62
|
MIT
|
package/README.zh-CN.md
CHANGED
|
@@ -45,6 +45,16 @@ herdr-remote plugin link
|
|
|
45
45
|
- 配置文件:`~/.config/herdr-remote/config.json`
|
|
46
46
|
- 运行状态:`~/.local/state/herdr-remote/runtime.json` (权限 `0600`)
|
|
47
47
|
|
|
48
|
+
## herdr 命令的查找方式
|
|
49
|
+
|
|
50
|
+
依次查找 `HERDR_BIN_PATH`、`PATH`,以及常见安装目录(`~/.local/bin`、`~/.cargo/bin`、
|
|
51
|
+
`~/bin`、`/opt/homebrew/bin`、`/usr/local/bin` 等)。服务管理器不会继承 shell 环境,
|
|
52
|
+
因此 `herdr-remote keepalive install` 会把找到的可执行文件路径和当前 `PATH` 写入
|
|
53
|
+
systemd unit 或 launchd plist。
|
|
54
|
+
|
|
55
|
+
`herdr-remote status --json` 会输出 `host.herdrCommand` 与 `host.herdrCommandFound`。
|
|
56
|
+
若显示未找到,请将 `HERDR_BIN_PATH` 设为完整路径后重新安装保活服务。
|
|
57
|
+
|
|
48
58
|
## 开源协议
|
|
49
59
|
|
|
50
60
|
MIT
|
package/dist/tui.mjs
CHANGED
|
@@ -76,7 +76,10 @@ var require_config = __commonJS({
|
|
|
76
76
|
publicUrl: "",
|
|
77
77
|
// Operator-run relay, e.g. wss://herdr.example.com (mode "remote" only).
|
|
78
78
|
remoteUrl: "",
|
|
79
|
-
|
|
79
|
+
// Kept in step with the relay package's own default: the CLI starts a
|
|
80
|
+
// relay of its own, and a smaller ceiling here would reject an upload the
|
|
81
|
+
// hosted relay accepts.
|
|
82
|
+
maxPayloadBytes: 5 * 1024 * 1024,
|
|
80
83
|
maxClientsPerHost: 16,
|
|
81
84
|
maxHosts: 1024,
|
|
82
85
|
maxPendingHandshakes: 1024,
|
|
@@ -526,7 +529,7 @@ var require_en = __commonJS({
|
|
|
526
529
|
"herdr.registerDone": "Registered: {path}",
|
|
527
530
|
"herdr.unregisterDone": "Plugin unregistered.",
|
|
528
531
|
"herdr.registerFailed": "Registration failed: {message}",
|
|
529
|
-
"herdr.cliMissing": "Command herdr not found
|
|
532
|
+
"herdr.cliMissing": "Command herdr not found. Set HERDR_BIN_PATH to its full path.",
|
|
530
533
|
"about.title": "Language & about",
|
|
531
534
|
"about.language": "Interface language",
|
|
532
535
|
"about.languageAuto": "Follow system ({detected})",
|
|
@@ -751,7 +754,7 @@ var require_zh = __commonJS({
|
|
|
751
754
|
"herdr.registerDone": "\u5DF2\u6CE8\u518C\uFF1A{path}",
|
|
752
755
|
"herdr.unregisterDone": "\u5DF2\u53D6\u6D88\u6CE8\u518C\u3002",
|
|
753
756
|
"herdr.registerFailed": "\u6CE8\u518C\u5931\u8D25\uFF1A{message}",
|
|
754
|
-
"herdr.cliMissing": "
|
|
757
|
+
"herdr.cliMissing": "\u672A\u627E\u5230 herdr \u547D\u4EE4\uFF0C\u53EF\u5C06 HERDR_BIN_PATH \u8BBE\u4E3A\u5176\u5B8C\u6574\u8DEF\u5F84\u3002",
|
|
755
758
|
"about.title": "\u8BED\u8A00\u4E0E\u5173\u4E8E",
|
|
756
759
|
"about.language": "\u754C\u9762\u8BED\u8A00",
|
|
757
760
|
"about.languageAuto": "\u8DDF\u968F\u7CFB\u7EDF\uFF08{detected}\uFF09",
|
|
@@ -1459,10 +1462,104 @@ var require_socket_discovery = __commonJS({
|
|
|
1459
1462
|
var require_herdr_command = __commonJS({
|
|
1460
1463
|
"src/herdr-command.js"(exports, module) {
|
|
1461
1464
|
"use strict";
|
|
1462
|
-
|
|
1463
|
-
|
|
1465
|
+
var fs = __require("node:fs");
|
|
1466
|
+
var os = __require("node:os");
|
|
1467
|
+
var path = __require("node:path");
|
|
1468
|
+
var COMMAND_NAME = "herdr";
|
|
1469
|
+
var FALLBACK_DIRECTORIES = [
|
|
1470
|
+
"~/.local/bin",
|
|
1471
|
+
"~/.cargo/bin",
|
|
1472
|
+
"~/bin",
|
|
1473
|
+
"/opt/homebrew/bin",
|
|
1474
|
+
"/usr/local/bin",
|
|
1475
|
+
"/home/linuxbrew/.linuxbrew/bin",
|
|
1476
|
+
"/usr/bin"
|
|
1477
|
+
];
|
|
1478
|
+
function expandHome(directory, home) {
|
|
1479
|
+
if (directory === "~") return home;
|
|
1480
|
+
if (directory.startsWith("~/")) return path.join(home, directory.slice(2));
|
|
1481
|
+
return directory;
|
|
1482
|
+
}
|
|
1483
|
+
function candidateNames(base = COMMAND_NAME) {
|
|
1484
|
+
return process.platform === "win32" ? [base, `${base}.exe`, `${base}.cmd`, `${base}.bat`] : [base];
|
|
1485
|
+
}
|
|
1486
|
+
function isExecutableFile(candidate) {
|
|
1487
|
+
try {
|
|
1488
|
+
if (!fs.statSync(candidate).isFile()) return false;
|
|
1489
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
1490
|
+
return true;
|
|
1491
|
+
} catch {
|
|
1492
|
+
return false;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
function findInDirectory(directory, names = candidateNames()) {
|
|
1496
|
+
if (!directory) return null;
|
|
1497
|
+
for (const name of names) {
|
|
1498
|
+
const candidate = path.join(directory, name);
|
|
1499
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
1500
|
+
}
|
|
1501
|
+
return null;
|
|
1502
|
+
}
|
|
1503
|
+
function findOnSearchPath(searchPath, names = candidateNames()) {
|
|
1504
|
+
for (const entry of String(searchPath || "").split(path.delimiter)) {
|
|
1505
|
+
const found = findInDirectory(entry.trim(), names);
|
|
1506
|
+
if (found) return found;
|
|
1507
|
+
}
|
|
1508
|
+
return null;
|
|
1464
1509
|
}
|
|
1465
|
-
|
|
1510
|
+
function fallbackDirectories(home = os.homedir()) {
|
|
1511
|
+
return FALLBACK_DIRECTORIES.map((directory) => expandHome(directory, home));
|
|
1512
|
+
}
|
|
1513
|
+
function looksLikePath(value) {
|
|
1514
|
+
return value.includes("/") || value.includes(path.sep);
|
|
1515
|
+
}
|
|
1516
|
+
function resolveOverride(value, searchPath) {
|
|
1517
|
+
const trimmed = String(value || "").trim();
|
|
1518
|
+
if (!trimmed) return null;
|
|
1519
|
+
if (looksLikePath(trimmed)) {
|
|
1520
|
+
const absolute = path.resolve(trimmed);
|
|
1521
|
+
if (isExecutableFile(absolute)) return absolute;
|
|
1522
|
+
return findInDirectory(absolute);
|
|
1523
|
+
}
|
|
1524
|
+
return findOnSearchPath(searchPath, candidateNames(trimmed));
|
|
1525
|
+
}
|
|
1526
|
+
function findHerdrCommand({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home) } = {}) {
|
|
1527
|
+
const override = resolveOverride(env.HERDR_BIN_PATH, env.PATH);
|
|
1528
|
+
if (override) return { command: override, source: "env", found: true };
|
|
1529
|
+
const onPath = findOnSearchPath(env.PATH);
|
|
1530
|
+
if (onPath) return { command: onPath, source: "path", found: true };
|
|
1531
|
+
for (const directory of directories) {
|
|
1532
|
+
const found = findInDirectory(directory);
|
|
1533
|
+
if (found) return { command: found, source: "fallback", found: true };
|
|
1534
|
+
}
|
|
1535
|
+
return { command: COMMAND_NAME, source: "unresolved", found: false };
|
|
1536
|
+
}
|
|
1537
|
+
function resolveHerdrCommand(options) {
|
|
1538
|
+
return findHerdrCommand(options).command;
|
|
1539
|
+
}
|
|
1540
|
+
function verifyHerdrCommand(command, options = {}) {
|
|
1541
|
+
if (typeof command === "string" && looksLikePath(command) && isExecutableFile(command)) {
|
|
1542
|
+
return { command, source: "verified", found: true };
|
|
1543
|
+
}
|
|
1544
|
+
return findHerdrCommand(options);
|
|
1545
|
+
}
|
|
1546
|
+
function herdrNotFoundMessage({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home) } = {}) {
|
|
1547
|
+
const override = String(env.HERDR_BIN_PATH || "").trim();
|
|
1548
|
+
const parts = [`Herdr executable "${COMMAND_NAME}" was not found.`];
|
|
1549
|
+
if (override) parts.push(`HERDR_BIN_PATH=${override} does not point at an executable.`);
|
|
1550
|
+
parts.push(directories.length ? `Searched PATH and ${directories.join(", ")}.` : "Searched PATH.");
|
|
1551
|
+
parts.push("Install Herdr or set HERDR_BIN_PATH to its full path, then restart herdr-remote.");
|
|
1552
|
+
return parts.join(" ");
|
|
1553
|
+
}
|
|
1554
|
+
module.exports = {
|
|
1555
|
+
COMMAND_NAME,
|
|
1556
|
+
FALLBACK_DIRECTORIES,
|
|
1557
|
+
fallbackDirectories,
|
|
1558
|
+
findHerdrCommand,
|
|
1559
|
+
herdrNotFoundMessage,
|
|
1560
|
+
resolveHerdrCommand,
|
|
1561
|
+
verifyHerdrCommand
|
|
1562
|
+
};
|
|
1466
1563
|
}
|
|
1467
1564
|
});
|
|
1468
1565
|
|
|
@@ -1496,7 +1593,7 @@ var require_service = __commonJS({
|
|
|
1496
1593
|
} = require_terminal_palette();
|
|
1497
1594
|
var { resolveSocketPath } = require_socket_discovery();
|
|
1498
1595
|
var { preferredLanAddress: preferredLanAddress2 } = require_net_interfaces();
|
|
1499
|
-
var { resolveHerdrCommand } = require_herdr_command();
|
|
1596
|
+
var { findHerdrCommand, resolveHerdrCommand } = require_herdr_command();
|
|
1500
1597
|
var RUNTIME_VERSION = 2;
|
|
1501
1598
|
function pidAlive(pid) {
|
|
1502
1599
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
@@ -1802,6 +1899,7 @@ var require_service = __commonJS({
|
|
|
1802
1899
|
health = { ok: false, message: error.message };
|
|
1803
1900
|
}
|
|
1804
1901
|
const socketPath = resolveSocketPath(config.herdr.socketPath);
|
|
1902
|
+
const herdr = findHerdrCommand();
|
|
1805
1903
|
return {
|
|
1806
1904
|
ok: true,
|
|
1807
1905
|
mode: config.relay.mode,
|
|
@@ -1819,7 +1917,10 @@ var require_service = __commonJS({
|
|
|
1819
1917
|
alive: pidAlive(state.hostPid),
|
|
1820
1918
|
hostId: state.hostId || null,
|
|
1821
1919
|
socketPath,
|
|
1822
|
-
socketExists: Boolean(socketPath) && fs.existsSync(socketPath)
|
|
1920
|
+
socketExists: Boolean(socketPath) && fs.existsSync(socketPath),
|
|
1921
|
+
herdrCommand: herdr.command,
|
|
1922
|
+
herdrCommandFound: herdr.found,
|
|
1923
|
+
herdrCommandSource: herdr.source
|
|
1823
1924
|
},
|
|
1824
1925
|
publicUrl: resolvePublicUrl2(config, lanAddress),
|
|
1825
1926
|
startedAt: state.startedAt || null
|
|
@@ -1896,6 +1997,7 @@ var require_keepalive = __commonJS({
|
|
|
1896
1997
|
var { PACKAGE_ROOT, loadConfig: loadConfig2, stateDir: stateDir2 } = require_config();
|
|
1897
1998
|
var { ensureDir, readJson, writeJsonAtomic } = require_state();
|
|
1898
1999
|
var { logPath, pidAlive } = require_service();
|
|
2000
|
+
var { findHerdrCommand } = require_herdr_command();
|
|
1899
2001
|
var SYSTEMD_UNIT_NAME = "herdr-remote.service";
|
|
1900
2002
|
var LAUNCHD_LABEL = "dev.herdr.remote";
|
|
1901
2003
|
var FALLBACK_PID_FILE = "supervisor.pid";
|
|
@@ -1936,8 +2038,28 @@ var require_keepalive = __commonJS({
|
|
|
1936
2038
|
}
|
|
1937
2039
|
return "supervisor";
|
|
1938
2040
|
}
|
|
2041
|
+
function servicePath({ env = process.env, herdrCommand = null } = {}) {
|
|
2042
|
+
const entries = String(env.PATH || "").split(path.delimiter).map((entry) => entry.trim()).filter(Boolean);
|
|
2043
|
+
if (herdrCommand) entries.push(path.dirname(herdrCommand));
|
|
2044
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2045
|
+
const unique = entries.filter((entry) => seen.has(entry) ? false : seen.add(entry));
|
|
2046
|
+
return unique.join(path.delimiter);
|
|
2047
|
+
}
|
|
2048
|
+
function serviceEnvironment({ env = process.env, home = os.homedir(), directories } = {}) {
|
|
2049
|
+
const environment = {};
|
|
2050
|
+
const herdr = findHerdrCommand({ env, home, ...directories ? { directories } : {} });
|
|
2051
|
+
if (herdr.found) environment.HERDR_BIN_PATH = herdr.command;
|
|
2052
|
+
const searchPath = servicePath({ env, herdrCommand: herdr.found ? herdr.command : null });
|
|
2053
|
+
if (searchPath) environment.PATH = searchPath;
|
|
2054
|
+
return environment;
|
|
2055
|
+
}
|
|
2056
|
+
function systemdEnvironmentLine(key, value) {
|
|
2057
|
+
const text = String(value).replace(/[\r\n]+/g, " ").replace(/%/g, "%%");
|
|
2058
|
+
if (/^[\w@+=:,./-]*$/.test(text)) return `Environment=${key}=${text}`;
|
|
2059
|
+
return `Environment="${key}=${text.replace(/([\\"])/g, "\\$1")}"`;
|
|
2060
|
+
}
|
|
1939
2061
|
function renderSystemdUnit({ nodePath = process.execPath, entryPoint = cliEntryPoint(), environment = {} } = {}) {
|
|
1940
|
-
const environmentLines = Object.entries(environment).map(([key, value]) =>
|
|
2062
|
+
const environmentLines = Object.entries(environment).map(([key, value]) => systemdEnvironmentLine(key, value)).join("\n");
|
|
1941
2063
|
return `[Unit]
|
|
1942
2064
|
Description=Herdr Remote (relay and host connector)
|
|
1943
2065
|
Documentation=https://github.com/herdr/herdr-remote
|
|
@@ -2025,7 +2147,7 @@ ${environmentBlock} <key>StandardOutPath</key>
|
|
|
2025
2147
|
function systemdInstall() {
|
|
2026
2148
|
const unitPath = systemdUnitPath();
|
|
2027
2149
|
ensureDir(path.dirname(unitPath));
|
|
2028
|
-
fs.writeFileSync(unitPath, renderSystemdUnit(), { mode: 420 });
|
|
2150
|
+
fs.writeFileSync(unitPath, renderSystemdUnit({ environment: serviceEnvironment() }), { mode: 420 });
|
|
2029
2151
|
const reload = systemctl(["daemon-reload"]);
|
|
2030
2152
|
if (reload.status !== 0) {
|
|
2031
2153
|
throw new Error(`systemctl --user daemon-reload failed: ${String(reload.stderr || "").trim()}`);
|
|
@@ -2065,7 +2187,7 @@ ${environmentBlock} <key>StandardOutPath</key>
|
|
|
2065
2187
|
const plistPath = launchdPlistPath();
|
|
2066
2188
|
ensureDir(path.dirname(plistPath));
|
|
2067
2189
|
ensureDir(stateDir2());
|
|
2068
|
-
fs.writeFileSync(plistPath, renderLaunchdPlist(), { mode: 420 });
|
|
2190
|
+
fs.writeFileSync(plistPath, renderLaunchdPlist({ environment: serviceEnvironment() }), { mode: 420 });
|
|
2069
2191
|
spawnSync("launchctl", ["bootout", `${launchdDomainTarget()}/${LAUNCHD_LABEL}`], { stdio: "ignore" });
|
|
2070
2192
|
const result = spawnSync("launchctl", ["bootstrap", launchdDomainTarget(), plistPath], { encoding: "utf8" });
|
|
2071
2193
|
if (result.status !== 0) {
|
|
@@ -2104,7 +2226,7 @@ ${environmentBlock} <key>StandardOutPath</key>
|
|
|
2104
2226
|
try {
|
|
2105
2227
|
const child = spawn(process.execPath, [cliEntryPoint(), "run", "--daemon"], {
|
|
2106
2228
|
cwd: PACKAGE_ROOT,
|
|
2107
|
-
env: { ...process.env, HERDR_REMOTE_SERVICE: "1" },
|
|
2229
|
+
env: { ...process.env, HERDR_REMOTE_SERVICE: "1", ...serviceEnvironment() },
|
|
2108
2230
|
detached: true,
|
|
2109
2231
|
stdio: ["ignore", logFd, logFd]
|
|
2110
2232
|
});
|
|
@@ -2209,6 +2331,9 @@ ${environmentBlock} <key>StandardOutPath</key>
|
|
|
2209
2331
|
renderSystemdUnit,
|
|
2210
2332
|
renderLaunchdPlist,
|
|
2211
2333
|
escapeXml,
|
|
2334
|
+
servicePath,
|
|
2335
|
+
serviceEnvironment,
|
|
2336
|
+
systemdEnvironmentLine,
|
|
2212
2337
|
status,
|
|
2213
2338
|
install,
|
|
2214
2339
|
uninstall,
|
|
@@ -2275,17 +2400,18 @@ var require_herdr_plugin = __commonJS({
|
|
|
2275
2400
|
var path = __require("node:path");
|
|
2276
2401
|
var { spawnSync } = __require("node:child_process");
|
|
2277
2402
|
var { PACKAGE_ROOT } = require_config();
|
|
2403
|
+
var { resolveHerdrCommand } = require_herdr_command();
|
|
2278
2404
|
var PLUGIN_ID = "herdr.remote.web";
|
|
2279
2405
|
var MANIFEST_NAME = "herdr-plugin.toml";
|
|
2280
2406
|
function manifestPath() {
|
|
2281
2407
|
return path.join(PACKAGE_ROOT, MANIFEST_NAME);
|
|
2282
2408
|
}
|
|
2283
2409
|
function herdrAvailable() {
|
|
2284
|
-
const result = spawnSync(
|
|
2410
|
+
const result = spawnSync(resolveHerdrCommand(), ["--version"], { stdio: "ignore" });
|
|
2285
2411
|
return result.status === 0 || result.status === 1;
|
|
2286
2412
|
}
|
|
2287
2413
|
function runHerdr(args, { timeout = 15e3 } = {}) {
|
|
2288
|
-
const result = spawnSync(
|
|
2414
|
+
const result = spawnSync(resolveHerdrCommand(), args, { encoding: "utf8", timeout });
|
|
2289
2415
|
if (result.error && result.error.code === "ENOENT") {
|
|
2290
2416
|
const error = new Error("herdr command not found");
|
|
2291
2417
|
error.code = "HERDR_NOT_FOUND";
|
|
@@ -3889,7 +4015,7 @@ function About({ ctx }) {
|
|
|
3889
4015
|
children: updateLabel
|
|
3890
4016
|
}
|
|
3891
4017
|
) }),
|
|
3892
|
-
/* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.
|
|
4018
|
+
/* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.9" }) }),
|
|
3893
4019
|
/* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
|
|
3894
4020
|
/* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
|
|
3895
4021
|
/* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
|
package/herdr-plugin.toml
CHANGED
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -62,7 +62,10 @@ const DEFAULTS = {
|
|
|
62
62
|
publicUrl: '',
|
|
63
63
|
// Operator-run relay, e.g. wss://herdr.example.com (mode "remote" only).
|
|
64
64
|
remoteUrl: '',
|
|
65
|
-
|
|
65
|
+
// Kept in step with the relay package's own default: the CLI starts a
|
|
66
|
+
// relay of its own, and a smaller ceiling here would reject an upload the
|
|
67
|
+
// hosted relay accepts.
|
|
68
|
+
maxPayloadBytes: 5 * 1024 * 1024,
|
|
66
69
|
maxClientsPerHost: 16,
|
|
67
70
|
maxHosts: 1024,
|
|
68
71
|
maxPendingHandshakes: 1024,
|
package/src/herdr-command.js
CHANGED
|
@@ -3,10 +3,157 @@
|
|
|
3
3
|
// How a spawned Herdr is located.
|
|
4
4
|
//
|
|
5
5
|
// `HERDR_BIN_PATH` is what the service layer hands to the host connector, and
|
|
6
|
-
// it is also the escape hatch for an install that is not on `PATH`.
|
|
6
|
+
// it is also the escape hatch for an install that is not on `PATH`. Neither is
|
|
7
|
+
// enough on its own once a service manager is in the picture: `systemd --user`
|
|
8
|
+
// and `launchd` start their units with a minimal `PATH` that contains none of
|
|
9
|
+
// the directories a user-level install writes to. A bare `herdr` then reaches
|
|
10
|
+
// `execvp(3)` inside the forked PTY child, which prints
|
|
11
|
+
// "execvp(3) failed.: No such file or directory" and exits — the failure the
|
|
12
|
+
// browser sees as an endless reconnect loop.
|
|
13
|
+
//
|
|
14
|
+
// So the name is resolved to an absolute path here, ahead of any spawn:
|
|
15
|
+
// an honoured override first, then `PATH`, then the handful of directories
|
|
16
|
+
// installers actually use.
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const COMMAND_NAME = 'herdr';
|
|
23
|
+
|
|
24
|
+
/** Where a user-level install lands, in the order we trust it. */
|
|
25
|
+
const FALLBACK_DIRECTORIES = [
|
|
26
|
+
'~/.local/bin',
|
|
27
|
+
'~/.cargo/bin',
|
|
28
|
+
'~/bin',
|
|
29
|
+
'/opt/homebrew/bin',
|
|
30
|
+
'/usr/local/bin',
|
|
31
|
+
'/home/linuxbrew/.linuxbrew/bin',
|
|
32
|
+
'/usr/bin',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function expandHome(directory, home) {
|
|
36
|
+
if (directory === '~') return home;
|
|
37
|
+
if (directory.startsWith('~/')) return path.join(home, directory.slice(2));
|
|
38
|
+
return directory;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Windows keeps the executable bit in the extension instead of the mode. */
|
|
42
|
+
function candidateNames(base = COMMAND_NAME) {
|
|
43
|
+
return process.platform === 'win32'
|
|
44
|
+
? [base, `${base}.exe`, `${base}.cmd`, `${base}.bat`]
|
|
45
|
+
: [base];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isExecutableFile(candidate) {
|
|
49
|
+
try {
|
|
50
|
+
if (!fs.statSync(candidate).isFile()) return false;
|
|
51
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findInDirectory(directory, names = candidateNames()) {
|
|
59
|
+
if (!directory) return null;
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
const candidate = path.join(directory, name);
|
|
62
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function findOnSearchPath(searchPath, names = candidateNames()) {
|
|
68
|
+
for (const entry of String(searchPath || '').split(path.delimiter)) {
|
|
69
|
+
const found = findInDirectory(entry.trim(), names);
|
|
70
|
+
if (found) return found;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fallbackDirectories(home = os.homedir()) {
|
|
76
|
+
return FALLBACK_DIRECTORIES.map((directory) => expandHome(directory, home));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function looksLikePath(value) {
|
|
80
|
+
return value.includes('/') || value.includes(path.sep);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Honour `HERDR_BIN_PATH`.
|
|
85
|
+
*
|
|
86
|
+
* It is documented as the executable, but a directory is an easy thing to
|
|
87
|
+
* write there, and a bare name — someone copying `herdr` into a unit file —
|
|
88
|
+
* still has to go through the search. A value that resolves to nothing is
|
|
89
|
+
* treated as absent rather than fatal: a stale override left behind by a
|
|
90
|
+
* moved install is precisely the case we are trying to survive.
|
|
91
|
+
*/
|
|
92
|
+
function resolveOverride(value, searchPath) {
|
|
93
|
+
const trimmed = String(value || '').trim();
|
|
94
|
+
if (!trimmed) return null;
|
|
95
|
+
if (looksLikePath(trimmed)) {
|
|
96
|
+
const absolute = path.resolve(trimmed);
|
|
97
|
+
if (isExecutableFile(absolute)) return absolute;
|
|
98
|
+
return findInDirectory(absolute);
|
|
99
|
+
}
|
|
100
|
+
return findOnSearchPath(searchPath, candidateNames(trimmed));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Everything known about where Herdr is, so callers can report it and not only
|
|
105
|
+
* spawn it.
|
|
106
|
+
*
|
|
107
|
+
* `source` is `env` for an honoured `HERDR_BIN_PATH`, `path` for a hit on
|
|
108
|
+
* `PATH`, `fallback` for one of the well-known install directories, and
|
|
109
|
+
* `unresolved` when nothing matched. In that last case `command` is still the
|
|
110
|
+
* bare name: `PATH` may be right in a way we cannot see from here, and letting
|
|
111
|
+
* the spawn proceed keeps the old behaviour for anyone relying on it.
|
|
112
|
+
*/
|
|
113
|
+
function findHerdrCommand({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home) } = {}) {
|
|
114
|
+
const override = resolveOverride(env.HERDR_BIN_PATH, env.PATH);
|
|
115
|
+
if (override) return { command: override, source: 'env', found: true };
|
|
116
|
+
const onPath = findOnSearchPath(env.PATH);
|
|
117
|
+
if (onPath) return { command: onPath, source: 'path', found: true };
|
|
118
|
+
for (const directory of directories) {
|
|
119
|
+
const found = findInDirectory(directory);
|
|
120
|
+
if (found) return { command: found, source: 'fallback', found: true };
|
|
121
|
+
}
|
|
122
|
+
return { command: COMMAND_NAME, source: 'unresolved', found: false };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveHerdrCommand(options) {
|
|
126
|
+
return findHerdrCommand(options).command;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Re-check a command resolved earlier. A connector outlives the install that
|
|
131
|
+
* was missing when it started, and an absolute path outlives the binary it
|
|
132
|
+
* pointed at, so neither answer stays true for the life of the service.
|
|
133
|
+
*/
|
|
134
|
+
function verifyHerdrCommand(command, options = {}) {
|
|
135
|
+
if (typeof command === 'string' && looksLikePath(command) && isExecutableFile(command)) {
|
|
136
|
+
return { command, source: 'verified', found: true };
|
|
137
|
+
}
|
|
138
|
+
return findHerdrCommand(options);
|
|
139
|
+
}
|
|
7
140
|
|
|
8
|
-
|
|
9
|
-
|
|
141
|
+
/** The one message a user needs to fix a missing install themselves. */
|
|
142
|
+
function herdrNotFoundMessage({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home) } = {}) {
|
|
143
|
+
const override = String(env.HERDR_BIN_PATH || '').trim();
|
|
144
|
+
const parts = [`Herdr executable "${COMMAND_NAME}" was not found.`];
|
|
145
|
+
if (override) parts.push(`HERDR_BIN_PATH=${override} does not point at an executable.`);
|
|
146
|
+
parts.push(directories.length ? `Searched PATH and ${directories.join(', ')}.` : 'Searched PATH.');
|
|
147
|
+
parts.push('Install Herdr or set HERDR_BIN_PATH to its full path, then restart herdr-remote.');
|
|
148
|
+
return parts.join(' ');
|
|
10
149
|
}
|
|
11
150
|
|
|
12
|
-
module.exports = {
|
|
151
|
+
module.exports = {
|
|
152
|
+
COMMAND_NAME,
|
|
153
|
+
FALLBACK_DIRECTORIES,
|
|
154
|
+
fallbackDirectories,
|
|
155
|
+
findHerdrCommand,
|
|
156
|
+
herdrNotFoundMessage,
|
|
157
|
+
resolveHerdrCommand,
|
|
158
|
+
verifyHerdrCommand,
|
|
159
|
+
};
|
package/src/herdr-plugin.js
CHANGED
|
@@ -10,6 +10,7 @@ const fs = require('node:fs');
|
|
|
10
10
|
const path = require('node:path');
|
|
11
11
|
const { spawnSync } = require('node:child_process');
|
|
12
12
|
const { PACKAGE_ROOT } = require('./config');
|
|
13
|
+
const { resolveHerdrCommand } = require('./herdr-command');
|
|
13
14
|
|
|
14
15
|
const PLUGIN_ID = 'herdr.remote.web';
|
|
15
16
|
const MANIFEST_NAME = 'herdr-plugin.toml';
|
|
@@ -18,13 +19,16 @@ function manifestPath() {
|
|
|
18
19
|
return path.join(PACKAGE_ROOT, MANIFEST_NAME);
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
// Registration also runs from `herdr plugin` actions and from the service
|
|
23
|
+
// manager, neither of which is guaranteed the PATH the user installed with, so
|
|
24
|
+
// the command is resolved rather than named.
|
|
21
25
|
function herdrAvailable() {
|
|
22
|
-
const result = spawnSync(
|
|
26
|
+
const result = spawnSync(resolveHerdrCommand(), ['--version'], { stdio: 'ignore' });
|
|
23
27
|
return result.status === 0 || result.status === 1;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
function runHerdr(args, { timeout = 15_000 } = {}) {
|
|
27
|
-
const result = spawnSync(
|
|
31
|
+
const result = spawnSync(resolveHerdrCommand(), args, { encoding: 'utf8', timeout });
|
|
28
32
|
if (result.error && result.error.code === 'ENOENT') {
|
|
29
33
|
const error = new Error('herdr command not found');
|
|
30
34
|
error.code = 'HERDR_NOT_FOUND';
|
package/src/host-connector.js
CHANGED
|
@@ -9,12 +9,13 @@ const { loadConfig, hostWebSocketUrl, resolveHostRelayUrl, stateDir } = require(
|
|
|
9
9
|
const { ensureDir } = require('./state');
|
|
10
10
|
const { resolveSocketPath, inspectSocket } = require('./socket-discovery');
|
|
11
11
|
const { PtySession } = require('./pty-session');
|
|
12
|
-
const { resolveHerdrCommand } = require('./herdr-command');
|
|
12
|
+
const { resolveHerdrCommand, verifyHerdrCommand, herdrNotFoundMessage } = require('./herdr-command');
|
|
13
13
|
// The wire format lives in the relay package so both ends of the protocol are
|
|
14
14
|
// generated from one definition.
|
|
15
15
|
const { packStreamFrame, unpackStreamFrame, PROTOCOL_VERSION } = require('herdr-remote-relay/protocol');
|
|
16
16
|
const { resolveHostPalette } = require('./terminal-palette');
|
|
17
17
|
const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
|
|
18
|
+
const { savePastedFile, cleanPastedDir } = require('./pasted-files');
|
|
18
19
|
|
|
19
20
|
function randomId(prefix) {
|
|
20
21
|
return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
|
|
@@ -40,6 +41,14 @@ function pidAlive(pid) {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
class HostConnector {
|
|
44
|
+
/**
|
|
45
|
+
* A session that dies this fast never really started. Anything longer is a
|
|
46
|
+
* session the user actually used and then left.
|
|
47
|
+
*/
|
|
48
|
+
static FAST_FAILURE_MS = 1500;
|
|
49
|
+
/** How many broken starts in a row before we stop calling them exits. */
|
|
50
|
+
static FAST_FAILURE_LIMIT = 3;
|
|
51
|
+
|
|
43
52
|
constructor(options = {}) {
|
|
44
53
|
const config = options.config || loadConfig();
|
|
45
54
|
this.config = config;
|
|
@@ -50,7 +59,10 @@ class HostConnector {
|
|
|
50
59
|
// Optional; a relay without a password accepts any workstation.
|
|
51
60
|
this.relayPassword = options.relayPassword ?? process.env.RELAY_PASSWORD ?? '';
|
|
52
61
|
this.socketPath = options.socketPath || resolveSocketPath(config.herdr.socketPath);
|
|
53
|
-
|
|
62
|
+
// Where to look for Herdr when the command has to be resolved again; the
|
|
63
|
+
// real environment unless a caller narrows it.
|
|
64
|
+
this.herdrLookup = options.herdrLookup || {};
|
|
65
|
+
this.herdrCommand = options.herdrCommand || resolveHerdrCommand(this.herdrLookup);
|
|
54
66
|
this.herdrArgs = options.herdrArgs || config.herdr.args;
|
|
55
67
|
this.cwd = options.cwd || config.herdr.cwd;
|
|
56
68
|
/**
|
|
@@ -65,6 +77,7 @@ class HostConnector {
|
|
|
65
77
|
: resolveHostPalette();
|
|
66
78
|
this.ws = null;
|
|
67
79
|
this.sessions = new Map();
|
|
80
|
+
this.fastFailures = 0;
|
|
68
81
|
this.reconnectTimer = null;
|
|
69
82
|
this.heartbeatTimer = null;
|
|
70
83
|
this.reconnectAttempts = 0;
|
|
@@ -77,6 +90,7 @@ class HostConnector {
|
|
|
77
90
|
|| process.env.HERDR_REMOTE_HOST_LOCK
|
|
78
91
|
|| path.join(stateDir(), 'host-connector.lock');
|
|
79
92
|
this.lockFd = null;
|
|
93
|
+
try { cleanPastedDir(); } catch {}
|
|
80
94
|
}
|
|
81
95
|
|
|
82
96
|
acquireLock() {
|
|
@@ -268,12 +282,42 @@ class HostConnector {
|
|
|
268
282
|
if (message.type === 'session_start') this.startSession(message);
|
|
269
283
|
else if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
|
|
270
284
|
else if (message.type === 'resize') this.resizeSession(message);
|
|
285
|
+
else if (message.type === 'paste_file') this.handlePasteFile(message);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Confirm Herdr is really there before the name reaches `pty.spawn`.
|
|
290
|
+
*
|
|
291
|
+
* node-pty resolves the command with `execvp(3)` inside the forked child, so
|
|
292
|
+
* a missing binary is not a spawn error anyone can catch: the child writes
|
|
293
|
+
* "execvp(3) failed.: No such file or directory" into the PTY and exits. The
|
|
294
|
+
* `session_exit` that follows makes the relay drop the browser's socket, the
|
|
295
|
+
* browser reconnects into the same broken start, and that is the reconnect
|
|
296
|
+
* loop of issue #1. Refusing to start keeps the socket up and puts the actual
|
|
297
|
+
* reason in front of the user.
|
|
298
|
+
*
|
|
299
|
+
* Re-resolved per session rather than cached from the constructor: a service
|
|
300
|
+
* that started before Herdr was installed should pick it up without a restart.
|
|
301
|
+
*/
|
|
302
|
+
ensureHerdrCommand() {
|
|
303
|
+
const resolved = verifyHerdrCommand(this.herdrCommand, this.herdrLookup);
|
|
304
|
+
if (resolved.found) {
|
|
305
|
+
this.herdrCommand = resolved.command;
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
return herdrNotFoundMessage(this.herdrLookup);
|
|
271
309
|
}
|
|
272
310
|
|
|
273
311
|
startSession(message) {
|
|
274
312
|
const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
|
|
275
313
|
if (!streamId) return;
|
|
276
314
|
this.stopSession(streamId);
|
|
315
|
+
const missingHerdr = this.ensureHerdrCommand();
|
|
316
|
+
if (missingHerdr) {
|
|
317
|
+
process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
|
|
318
|
+
sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_not_found', message: missingHerdr });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
277
321
|
const socketInfo = inspectSocket(this.socketPath);
|
|
278
322
|
if (!socketInfo.ok) {
|
|
279
323
|
sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: socketInfo.reason });
|
|
@@ -291,6 +335,7 @@ class HostConnector {
|
|
|
291
335
|
cols: Number(message.cols) || PtySession.DEFAULT_COLS,
|
|
292
336
|
rows: Number(message.rows) || PtySession.DEFAULT_ROWS,
|
|
293
337
|
createdAt: new Date().toISOString(),
|
|
338
|
+
startedAtMs: Date.now(),
|
|
294
339
|
clientId: streamId,
|
|
295
340
|
};
|
|
296
341
|
try {
|
|
@@ -304,7 +349,7 @@ class HostConnector {
|
|
|
304
349
|
onExit: ({ exitCode }) => {
|
|
305
350
|
if (this.sessions.get(streamId) !== session) return;
|
|
306
351
|
this.sessions.delete(streamId);
|
|
307
|
-
|
|
352
|
+
this.reportSessionExit(session, exitCode);
|
|
308
353
|
this.sendHeartbeat();
|
|
309
354
|
},
|
|
310
355
|
});
|
|
@@ -318,6 +363,32 @@ class HostConnector {
|
|
|
318
363
|
this.sendHeartbeat();
|
|
319
364
|
}
|
|
320
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Tell the relay how a session ended.
|
|
368
|
+
*
|
|
369
|
+
* `session_exit` is the honest answer for a session that ran, and the relay
|
|
370
|
+
* closes the browser's socket on it so the window can start a fresh one. That
|
|
371
|
+
* is also what turns a start that keeps failing into a reconnect loop: exit,
|
|
372
|
+
* close, reconnect, exit. Once a few starts in a row have died immediately
|
|
373
|
+
* with a non-zero code the failure is systemic, not a session ending, so it is
|
|
374
|
+
* reported as an error — which the relay forwards without dropping the socket,
|
|
375
|
+
* leaving the user with a message instead of a spinner.
|
|
376
|
+
*/
|
|
377
|
+
reportSessionExit(session, exitCode) {
|
|
378
|
+
const streamId = session.id;
|
|
379
|
+
const lifetimeMs = Date.now() - session.startedAtMs;
|
|
380
|
+
const failedFast = exitCode !== 0 && lifetimeMs < HostConnector.FAST_FAILURE_MS;
|
|
381
|
+
this.fastFailures = failedFast ? this.fastFailures + 1 : 0;
|
|
382
|
+
if (this.fastFailures < HostConnector.FAST_FAILURE_LIMIT) {
|
|
383
|
+
sendJson(this.ws, { type: 'session_exit', clientId: streamId, code: exitCode });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const message = `"${this.herdrCommand}" exited immediately with code ${exitCode} on ${this.fastFailures} attempts in a row. `
|
|
387
|
+
+ 'Check that it runs from a terminal, and see the host connector log for what it printed.';
|
|
388
|
+
process.stderr.write(`herdr-remote host connector: ${message}\n`);
|
|
389
|
+
sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_start_failed', message });
|
|
390
|
+
}
|
|
391
|
+
|
|
321
392
|
stopSession(streamId) {
|
|
322
393
|
const session = this.sessions.get(streamId);
|
|
323
394
|
if (!session) return;
|
|
@@ -351,6 +422,28 @@ class HostConnector {
|
|
|
351
422
|
session.pty.resize(session.cols, session.rows);
|
|
352
423
|
}
|
|
353
424
|
|
|
425
|
+
handlePasteFile(message) {
|
|
426
|
+
const streamId = message.clientId || message.streamId;
|
|
427
|
+
try {
|
|
428
|
+
const savedPath = savePastedFile({
|
|
429
|
+
mime: message.mime,
|
|
430
|
+
dataBase64: message.dataBase64,
|
|
431
|
+
});
|
|
432
|
+
sendJson(this.ws, {
|
|
433
|
+
type: 'paste_file_ready',
|
|
434
|
+
clientId: streamId,
|
|
435
|
+
path: savedPath,
|
|
436
|
+
});
|
|
437
|
+
} catch (error) {
|
|
438
|
+
sendJson(this.ws, {
|
|
439
|
+
type: 'error',
|
|
440
|
+
clientId: streamId,
|
|
441
|
+
code: 'paste_file_write_failed',
|
|
442
|
+
message: error.message || 'Failed to save pasted file',
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
354
447
|
destroySessions() {
|
|
355
448
|
for (const session of this.sessions.values()) session.pty.kill();
|
|
356
449
|
this.sessions.clear();
|
package/src/i18n/en.js
CHANGED
|
@@ -154,7 +154,7 @@ module.exports = {
|
|
|
154
154
|
'herdr.registerDone': 'Registered: {path}',
|
|
155
155
|
'herdr.unregisterDone': 'Plugin unregistered.',
|
|
156
156
|
'herdr.registerFailed': 'Registration failed: {message}',
|
|
157
|
-
'herdr.cliMissing': 'Command herdr not found
|
|
157
|
+
'herdr.cliMissing': 'Command herdr not found. Set HERDR_BIN_PATH to its full path.',
|
|
158
158
|
|
|
159
159
|
'about.title': 'Language & about',
|
|
160
160
|
'about.language': 'Interface language',
|
package/src/i18n/zh.js
CHANGED
|
@@ -153,7 +153,7 @@ module.exports = {
|
|
|
153
153
|
'herdr.registerDone': '已注册:{path}',
|
|
154
154
|
'herdr.unregisterDone': '已取消注册。',
|
|
155
155
|
'herdr.registerFailed': '注册失败:{message}',
|
|
156
|
-
'herdr.cliMissing': '
|
|
156
|
+
'herdr.cliMissing': '未找到 herdr 命令,可将 HERDR_BIN_PATH 设为其完整路径。',
|
|
157
157
|
|
|
158
158
|
'about.title': '语言与关于',
|
|
159
159
|
'about.language': '界面语言',
|
package/src/keepalive.js
CHANGED
|
@@ -15,6 +15,7 @@ const { spawn, spawnSync } = require('node:child_process');
|
|
|
15
15
|
const { PACKAGE_ROOT, loadConfig, stateDir } = require('./config');
|
|
16
16
|
const { ensureDir, readJson, writeJsonAtomic } = require('./state');
|
|
17
17
|
const { logPath, pidAlive } = require('./service');
|
|
18
|
+
const { findHerdrCommand } = require('./herdr-command');
|
|
18
19
|
|
|
19
20
|
const SYSTEMD_UNIT_NAME = 'herdr-remote.service';
|
|
20
21
|
const LAUNCHD_LABEL = 'dev.herdr.remote';
|
|
@@ -71,13 +72,65 @@ function detectManager(preference = 'auto') {
|
|
|
71
72
|
return 'supervisor';
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// The environment a supervised copy needs
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `PATH` for the unit: the one that is resolving commands right now, plus the
|
|
81
|
+
* directory Herdr was found in.
|
|
82
|
+
*
|
|
83
|
+
* A service manager does not inherit the shell's environment. `systemd --user`
|
|
84
|
+
* hands a unit something close to `/usr/local/bin:/usr/bin:/bin` and `launchd`
|
|
85
|
+
* is no more generous, so a copy that works from a terminal loses `~/.local/bin`
|
|
86
|
+
* — and with it `herdr` — the moment it is installed as a service. Freezing the
|
|
87
|
+
* installing shell's `PATH` into the unit keeps the supervised copy able to find
|
|
88
|
+
* Herdr, and Herdr able to find the tools it spawns in turn.
|
|
89
|
+
*/
|
|
90
|
+
function servicePath({ env = process.env, herdrCommand = null } = {}) {
|
|
91
|
+
const entries = String(env.PATH || '')
|
|
92
|
+
.split(path.delimiter)
|
|
93
|
+
.map((entry) => entry.trim())
|
|
94
|
+
.filter(Boolean);
|
|
95
|
+
// The override already pins the exact binary; this is only so the session
|
|
96
|
+
// itself can still reach it by name.
|
|
97
|
+
if (herdrCommand) entries.push(path.dirname(herdrCommand));
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
const unique = entries.filter((entry) => (seen.has(entry) ? false : seen.add(entry)));
|
|
100
|
+
return unique.join(path.delimiter);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What has to be written into the unit file for a supervised start to behave
|
|
105
|
+
* like the one the user just ran by hand.
|
|
106
|
+
*/
|
|
107
|
+
function serviceEnvironment({ env = process.env, home = os.homedir(), directories } = {}) {
|
|
108
|
+
const environment = {};
|
|
109
|
+
const herdr = findHerdrCommand({ env, home, ...(directories ? { directories } : {}) });
|
|
110
|
+
if (herdr.found) environment.HERDR_BIN_PATH = herdr.command;
|
|
111
|
+
const searchPath = servicePath({ env, herdrCommand: herdr.found ? herdr.command : null });
|
|
112
|
+
if (searchPath) environment.PATH = searchPath;
|
|
113
|
+
return environment;
|
|
114
|
+
}
|
|
115
|
+
|
|
74
116
|
// ---------------------------------------------------------------------------
|
|
75
117
|
// Unit file rendering (pure, so it can be asserted in tests)
|
|
76
118
|
// ---------------------------------------------------------------------------
|
|
77
119
|
|
|
120
|
+
/**
|
|
121
|
+
* One `KEY=value` per directive. An unquoted systemd value ends at the first
|
|
122
|
+
* space, and `%` starts a specifier, so a `PATH` with either in it would be
|
|
123
|
+
* silently truncated or rewritten.
|
|
124
|
+
*/
|
|
125
|
+
function systemdEnvironmentLine(key, value) {
|
|
126
|
+
const text = String(value).replace(/[\r\n]+/g, ' ').replace(/%/g, '%%');
|
|
127
|
+
if (/^[\w@+=:,./-]*$/.test(text)) return `Environment=${key}=${text}`;
|
|
128
|
+
return `Environment="${key}=${text.replace(/([\\"])/g, '\\$1')}"`;
|
|
129
|
+
}
|
|
130
|
+
|
|
78
131
|
function renderSystemdUnit({ nodePath = process.execPath, entryPoint = cliEntryPoint(), environment = {} } = {}) {
|
|
79
132
|
const environmentLines = Object.entries(environment)
|
|
80
|
-
.map(([key, value]) =>
|
|
133
|
+
.map(([key, value]) => systemdEnvironmentLine(key, value))
|
|
81
134
|
.join('\n');
|
|
82
135
|
return `[Unit]
|
|
83
136
|
Description=Herdr Remote (relay and host connector)
|
|
@@ -179,7 +232,7 @@ function systemdStatus() {
|
|
|
179
232
|
function systemdInstall() {
|
|
180
233
|
const unitPath = systemdUnitPath();
|
|
181
234
|
ensureDir(path.dirname(unitPath));
|
|
182
|
-
fs.writeFileSync(unitPath, renderSystemdUnit(), { mode: 0o644 });
|
|
235
|
+
fs.writeFileSync(unitPath, renderSystemdUnit({ environment: serviceEnvironment() }), { mode: 0o644 });
|
|
183
236
|
const reload = systemctl(['daemon-reload']);
|
|
184
237
|
if (reload.status !== 0) {
|
|
185
238
|
throw new Error(`systemctl --user daemon-reload failed: ${String(reload.stderr || '').trim()}`);
|
|
@@ -227,7 +280,7 @@ function launchdInstall() {
|
|
|
227
280
|
const plistPath = launchdPlistPath();
|
|
228
281
|
ensureDir(path.dirname(plistPath));
|
|
229
282
|
ensureDir(stateDir());
|
|
230
|
-
fs.writeFileSync(plistPath, renderLaunchdPlist(), { mode: 0o644 });
|
|
283
|
+
fs.writeFileSync(plistPath, renderLaunchdPlist({ environment: serviceEnvironment() }), { mode: 0o644 });
|
|
231
284
|
// bootout first so a re-install picks up the rewritten plist.
|
|
232
285
|
spawnSync('launchctl', ['bootout', `${launchdDomainTarget()}/${LAUNCHD_LABEL}`], { stdio: 'ignore' });
|
|
233
286
|
const result = spawnSync('launchctl', ['bootstrap', launchdDomainTarget(), plistPath], { encoding: 'utf8' });
|
|
@@ -275,7 +328,7 @@ function fallbackInstall() {
|
|
|
275
328
|
try {
|
|
276
329
|
const child = spawn(process.execPath, [cliEntryPoint(), 'run', '--daemon'], {
|
|
277
330
|
cwd: PACKAGE_ROOT,
|
|
278
|
-
env: { ...process.env, HERDR_REMOTE_SERVICE: '1' },
|
|
331
|
+
env: { ...process.env, HERDR_REMOTE_SERVICE: '1', ...serviceEnvironment() },
|
|
279
332
|
detached: true,
|
|
280
333
|
stdio: ['ignore', logFd, logFd],
|
|
281
334
|
});
|
|
@@ -394,6 +447,9 @@ module.exports = {
|
|
|
394
447
|
renderSystemdUnit,
|
|
395
448
|
renderLaunchdPlist,
|
|
396
449
|
escapeXml,
|
|
450
|
+
servicePath,
|
|
451
|
+
serviceEnvironment,
|
|
452
|
+
systemdEnvironmentLine,
|
|
397
453
|
status,
|
|
398
454
|
install,
|
|
399
455
|
uninstall,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Secure file storage and validation for pasted clipboard images.
|
|
5
|
+
*
|
|
6
|
+
* Security requirements:
|
|
7
|
+
* 1. Pinned directory: writes strictly to path.join(stateDir(), 'pasted') (directory mode 0o700).
|
|
8
|
+
* 2. Unpredictable filename: generated via crypto.randomUUID(), never using client-provided strings.
|
|
9
|
+
* 3. Whitelisted extension: derived exclusively from verified MIME type.
|
|
10
|
+
* 4. Host-side size check: re-validated to enforce <= 3 MB independently of relay checks.
|
|
11
|
+
* 5. Magic bytes sniffing: inspects raw binary headers (PNG, JPEG, GIF, WebP) to prevent file-type spoofing.
|
|
12
|
+
* 6. File mode: files are created with mode 0o600.
|
|
13
|
+
* 7. Automatic pruning: caps at 20 files and 50 MB (oldest deleted first), removes >24h stale files at startup.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const crypto = require('node:crypto');
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const { stateDir } = require('./config');
|
|
20
|
+
const { ensureDir } = require('./state');
|
|
21
|
+
|
|
22
|
+
const MAX_PASTE_BYTES = 3 * 1024 * 1024; // 3 MB raw payload
|
|
23
|
+
const MAX_SAVED_FILES = 20;
|
|
24
|
+
const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
25
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
26
|
+
|
|
27
|
+
const MIME_CONFIG = {
|
|
28
|
+
'image/png': {
|
|
29
|
+
ext: '.png',
|
|
30
|
+
check: (buf) =>
|
|
31
|
+
buf.length >= 8 &&
|
|
32
|
+
buf[0] === 0x89 &&
|
|
33
|
+
buf[1] === 0x50 &&
|
|
34
|
+
buf[2] === 0x4e &&
|
|
35
|
+
buf[3] === 0x47,
|
|
36
|
+
},
|
|
37
|
+
'image/jpeg': {
|
|
38
|
+
ext: '.jpg',
|
|
39
|
+
check: (buf) =>
|
|
40
|
+
buf.length >= 3 &&
|
|
41
|
+
buf[0] === 0xff &&
|
|
42
|
+
buf[1] === 0xd8 &&
|
|
43
|
+
buf[2] === 0xff,
|
|
44
|
+
},
|
|
45
|
+
'image/webp': {
|
|
46
|
+
ext: '.webp',
|
|
47
|
+
check: (buf) =>
|
|
48
|
+
buf.length >= 12 &&
|
|
49
|
+
buf[0] === 0x52 &&
|
|
50
|
+
buf[1] === 0x49 &&
|
|
51
|
+
buf[2] === 0x46 &&
|
|
52
|
+
buf[3] === 0x46 && // 'RIFF'
|
|
53
|
+
buf[8] === 0x57 &&
|
|
54
|
+
buf[9] === 0x45 &&
|
|
55
|
+
buf[10] === 0x42 &&
|
|
56
|
+
buf[11] === 0x50, // 'WEBP'
|
|
57
|
+
},
|
|
58
|
+
'image/gif': {
|
|
59
|
+
ext: '.gif',
|
|
60
|
+
check: (buf) =>
|
|
61
|
+
buf.length >= 6 &&
|
|
62
|
+
buf[0] === 0x47 &&
|
|
63
|
+
buf[1] === 0x49 &&
|
|
64
|
+
buf[2] === 0x46 &&
|
|
65
|
+
buf[3] === 0x38, // 'GIF8'
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function getPastedDir() {
|
|
70
|
+
const dir = path.join(stateDir(), 'pasted');
|
|
71
|
+
ensureDir(dir);
|
|
72
|
+
return dir;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Prunes the pasted directory:
|
|
77
|
+
* - Removes files older than maxAgeMs (default 24 hours).
|
|
78
|
+
* - Caps total file count (default 20) and total disk footprint (default 50 MB),
|
|
79
|
+
* deleting oldest files first.
|
|
80
|
+
*/
|
|
81
|
+
function cleanPastedDir({
|
|
82
|
+
dir = getPastedDir(),
|
|
83
|
+
maxFiles = MAX_SAVED_FILES,
|
|
84
|
+
maxBytes = MAX_TOTAL_BYTES,
|
|
85
|
+
maxAgeMs = MAX_AGE_MS,
|
|
86
|
+
now = Date.now(),
|
|
87
|
+
} = {}) {
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const fileInfos = [];
|
|
96
|
+
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!entry.isFile()) continue;
|
|
99
|
+
const fullPath = path.join(dir, entry.name);
|
|
100
|
+
try {
|
|
101
|
+
const stat = fs.statSync(fullPath);
|
|
102
|
+
if (now - stat.mtimeMs > maxAgeMs) {
|
|
103
|
+
try { fs.unlinkSync(fullPath); } catch {}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
fileInfos.push({ path: fullPath, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Sort oldest first
|
|
111
|
+
fileInfos.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
112
|
+
|
|
113
|
+
let totalSize = fileInfos.reduce((sum, f) => sum + f.size, 0);
|
|
114
|
+
|
|
115
|
+
while (fileInfos.length > maxFiles || totalSize > maxBytes) {
|
|
116
|
+
const oldest = fileInfos.shift();
|
|
117
|
+
if (!oldest) break;
|
|
118
|
+
try {
|
|
119
|
+
fs.unlinkSync(oldest.path);
|
|
120
|
+
totalSize -= oldest.size;
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validates base64 data, checks payload limits, sniffs magic bytes against declared MIME,
|
|
127
|
+
* writes to state storage with mode 0o600, and returns the absolute local path.
|
|
128
|
+
*/
|
|
129
|
+
function savePastedFile({ mime, dataBase64, dir = getPastedDir() }) {
|
|
130
|
+
if (typeof mime !== 'string' || !Object.hasOwn(MIME_CONFIG, mime)) {
|
|
131
|
+
throw new Error(`unsupported MIME type: ${mime}`);
|
|
132
|
+
}
|
|
133
|
+
if (typeof dataBase64 !== 'string') {
|
|
134
|
+
throw new Error('missing or invalid dataBase64 payload');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const buf = Buffer.from(dataBase64, 'base64');
|
|
138
|
+
if (buf.length > MAX_PASTE_BYTES) {
|
|
139
|
+
throw new Error(`file size (${buf.length} bytes) exceeds maximum limit of 3 MB`);
|
|
140
|
+
}
|
|
141
|
+
if (buf.length === 0) {
|
|
142
|
+
throw new Error('file payload is empty');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const config = MIME_CONFIG[mime];
|
|
146
|
+
if (!config.check(buf)) {
|
|
147
|
+
throw new Error(`file magic bytes do not match declared MIME type ${mime}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Prune before writing new file
|
|
151
|
+
cleanPastedDir({ dir });
|
|
152
|
+
|
|
153
|
+
const fileName = `${crypto.randomUUID()}${config.ext}`;
|
|
154
|
+
const filePath = path.join(dir, fileName);
|
|
155
|
+
|
|
156
|
+
fs.writeFileSync(filePath, buf, { mode: 0o600, flag: 'wx' });
|
|
157
|
+
try {
|
|
158
|
+
fs.chmodSync(filePath, 0o600);
|
|
159
|
+
} catch {}
|
|
160
|
+
|
|
161
|
+
// Prune again after writing to strictly observe count/size ceiling
|
|
162
|
+
cleanPastedDir({ dir });
|
|
163
|
+
|
|
164
|
+
return filePath;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
MAX_PASTE_BYTES,
|
|
169
|
+
MAX_SAVED_FILES,
|
|
170
|
+
MAX_TOTAL_BYTES,
|
|
171
|
+
MAX_AGE_MS,
|
|
172
|
+
MIME_CONFIG,
|
|
173
|
+
getPastedDir,
|
|
174
|
+
cleanPastedDir,
|
|
175
|
+
savePastedFile,
|
|
176
|
+
};
|
package/src/service.js
CHANGED
|
@@ -26,7 +26,7 @@ const {
|
|
|
26
26
|
} = require('./terminal-palette');
|
|
27
27
|
const { resolveSocketPath } = require('./socket-discovery');
|
|
28
28
|
const { preferredLanAddress } = require('./net-interfaces');
|
|
29
|
-
const { resolveHerdrCommand } = require('./herdr-command');
|
|
29
|
+
const { findHerdrCommand, resolveHerdrCommand } = require('./herdr-command');
|
|
30
30
|
|
|
31
31
|
const RUNTIME_VERSION = 2;
|
|
32
32
|
|
|
@@ -415,6 +415,9 @@ async function statusServices() {
|
|
|
415
415
|
health = { ok: false, message: error.message };
|
|
416
416
|
}
|
|
417
417
|
const socketPath = resolveSocketPath(config.herdr.socketPath);
|
|
418
|
+
// Where Herdr was found, and whether it was found at all: under a service
|
|
419
|
+
// manager this is the first thing to check when sessions will not start.
|
|
420
|
+
const herdr = findHerdrCommand();
|
|
418
421
|
return {
|
|
419
422
|
ok: true,
|
|
420
423
|
mode: config.relay.mode,
|
|
@@ -433,6 +436,9 @@ async function statusServices() {
|
|
|
433
436
|
hostId: state.hostId || null,
|
|
434
437
|
socketPath,
|
|
435
438
|
socketExists: Boolean(socketPath) && fs.existsSync(socketPath),
|
|
439
|
+
herdrCommand: herdr.command,
|
|
440
|
+
herdrCommandFound: herdr.found,
|
|
441
|
+
herdrCommandSource: herdr.source,
|
|
436
442
|
},
|
|
437
443
|
publicUrl: resolvePublicUrl(config, lanAddress),
|
|
438
444
|
startedAt: state.startedAt || null,
|