clauderipple 0.9.5 → 0.11.0
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 +118 -0
- package/README.es.md +45 -0
- package/README.ja.md +17 -0
- package/README.ko.md +31 -0
- package/README.md +70 -0
- package/README.pt-BR.md +45 -0
- package/README.zh-CN.md +17 -0
- package/dist/app/assets/icon.icns +0 -0
- package/dist/app/assets/icon.ico +0 -0
- package/dist/app/assets/icon.png +0 -0
- package/dist/app/assets/trayDirectTemplate.png +0 -0
- package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
- package/dist/app/assets/trayDownTemplate.png +0 -0
- package/dist/app/assets/trayDownTemplate@2x.png +0 -0
- package/dist/app/assets/trayTemplate.png +0 -0
- package/dist/app/assets/trayTemplate@2x.png +0 -0
- package/dist/app/assets/trayWarnTemplate.png +0 -0
- package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
- package/dist/app/assets/trayWin.png +0 -0
- package/dist/app/assets/trayWin@2x.png +0 -0
- package/dist/app/assets/trayWinDirect.png +0 -0
- package/dist/app/assets/trayWinDirect@2x.png +0 -0
- package/dist/app/assets/trayWinDown.png +0 -0
- package/dist/app/assets/trayWinDown@2x.png +0 -0
- package/dist/app/assets/trayWinWarn.png +0 -0
- package/dist/app/assets/trayWinWarn@2x.png +0 -0
- package/dist/app/dist/claude-desktop.js +67 -0
- package/dist/app/dist/main.js +423 -110
- package/dist/app/dist/preload.js +21 -0
- package/dist/app/dist/protocol.js +121 -0
- package/dist/app/dist/status-model.js +87 -0
- package/dist/app/dist/window.js +133 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/awake-setup.js +314 -0
- package/dist/cli/src/bundle.js +9 -5
- package/dist/cli/src/direct.js +159 -0
- package/dist/cli/src/doctor.js +205 -0
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +227 -31
- package/dist/cli/src/launchd.js +29 -20
- package/dist/cli/src/launcher.js +215 -0
- package/dist/cli/src/power.js +182 -0
- package/dist/cli/src/registration.js +157 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +50 -40
- package/dist/cli/src/settings.js +26 -1
- package/dist/cli/src/tray.js +5 -2
- package/dist/mod/hooks/i18n.ts +473 -227
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +237 -32
- package/dist/mod/hooks/session.ts +102 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +903 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +120 -4
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +408 -27
- package/dist/mod/types/index.d.ts +66 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +336 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +331 -5
- package/dist/router/src/awake-win.js +590 -0
- package/dist/router/src/awake.js +432 -0
- package/dist/router/src/config.js +94 -0
- package/dist/router/src/health.js +86 -3
- package/dist/router/src/hold.js +107 -0
- package/dist/router/src/index.js +108 -6
- package/dist/router/src/ingress/server.js +26 -4
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/power.js +107 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/google/antigravity.js +73 -10
- package/dist/router/src/providers/google/index.js +191 -14
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
- package/dist/router/src/providers/google/transport.js +1 -1
- package/dist/router/src/providers/google/usage.js +302 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/translate.js +13 -2
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/providers/retry.js +4 -1
- package/dist/router/src/proxy.js +371 -261
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/tether-win.js +211 -0
- package/dist/router/src/tether.js +712 -0
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +1083 -59
- package/dist/ui/i18n.js +670 -69
- package/dist/ui/index.html +33 -6
- package/dist/ui/style.css +177 -0
- package/docs/ARCHITECTURE.md +402 -14
- package/package.json +8 -2
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// A remote (SSH) host that runs Claude Code for a Claude Desktop session (issue #67).
|
|
2
|
+
//
|
|
3
|
+
// Desktop's Code tab can run a session on a Linux host over SSH. Claude Code then runs *there*,
|
|
4
|
+
// reads that host's `~/.claude/settings.json`, and has no route to this router: 127.0.0.1 on the
|
|
5
|
+
// remote is the remote. Desktop opens its SSH connection with the bundled ssh2 library, which only
|
|
6
|
+
// forwards local→remote and never reads `RemoteForward` from ~/.ssh/config, so the router holds its
|
|
7
|
+
// own reverse tunnel (remote-tunnel.ts) and this module puts on the remote what a session there
|
|
8
|
+
// needs: the CA certificate, the env that points Claude Code at the tunnel, and the worker agent
|
|
9
|
+
// files. See docs/ARCHITECTURE.md "Remote (SSH) sessions".
|
|
10
|
+
//
|
|
11
|
+
// Everything on the remote is done by POSIX `sh` scripts fed to `ssh <host> sh -s` on stdin. No
|
|
12
|
+
// node or python there, no quoting of a command line through whatever login shell the remote user
|
|
13
|
+
// has, and the host name only ever travels as its own argv element — never through a local shell.
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
/** The remote port the tunnel listens on (remote 127.0.0.1) unless `--port` says otherwise. */
|
|
19
|
+
export const DEFAULT_REMOTE_PORT = 18791;
|
|
20
|
+
/** Our directory under the remote home: the CA copy and the record of what we put there. */
|
|
21
|
+
export const REMOTE_DIR = ".clauderipple-remote";
|
|
22
|
+
/** The remote settings.json is copied here once, before the first time we change it. */
|
|
23
|
+
export const SETTINGS_BACKUP_SUFFIX = ".clauderipple-backup";
|
|
24
|
+
/**
|
|
25
|
+
* Why `host` cannot be handed to ssh, or null when it can. An ssh alias or `user@host`, nothing
|
|
26
|
+
* else: a leading `-` would be read as an option (`-oProxyCommand=…` runs a command), and anything
|
|
27
|
+
* outside a hostname's characters has no business in one.
|
|
28
|
+
*/
|
|
29
|
+
export function hostProblem(host) {
|
|
30
|
+
if (typeof host !== "string" || host === "")
|
|
31
|
+
return "an ssh host is required (an ssh alias or user@host)";
|
|
32
|
+
if (host.length > 255)
|
|
33
|
+
return "the ssh host is longer than 255 characters";
|
|
34
|
+
if (host.startsWith("-"))
|
|
35
|
+
return `"${host}" starts with "-", which ssh would read as an option`;
|
|
36
|
+
if (!/^[A-Za-z0-9_.@:-]+$/.test(host))
|
|
37
|
+
return `"${host}" is not an ssh alias or user@host (letters, digits and . _ @ : - only)`;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
export function portProblem(port) {
|
|
41
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? null : `port must be an integer from 1 to 65535`;
|
|
42
|
+
}
|
|
43
|
+
/** The OpenSSH client: Windows ships it as an optional feature in System32\OpenSSH. */
|
|
44
|
+
export function sshExecutable() {
|
|
45
|
+
if (process.platform !== "win32")
|
|
46
|
+
return "ssh";
|
|
47
|
+
const builtIn = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "OpenSSH", "ssh.exe");
|
|
48
|
+
return fs.existsSync(builtIn) ? builtIn : "ssh.exe";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Extra ssh arguments from `CLAUDERIPPLE_SSH_OPTIONS` (a JSON array of strings), placed before the
|
|
52
|
+
* host. Exists for the end-to-end test, which points ssh at a throwaway sshd with its own key and
|
|
53
|
+
* known_hosts instead of the user's ssh setup; nothing in the product sets it.
|
|
54
|
+
*/
|
|
55
|
+
export function extraSshOptions() {
|
|
56
|
+
const raw = process.env.CLAUDERIPPLE_SSH_OPTIONS;
|
|
57
|
+
if (!raw)
|
|
58
|
+
return [];
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
if (!Array.isArray(parsed) || parsed.some((a) => typeof a !== "string"))
|
|
61
|
+
throw new Error("CLAUDERIPPLE_SSH_OPTIONS must be a JSON array of strings");
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
/** Never ask: a prompt nobody can answer would hang the router or the CLI. */
|
|
65
|
+
export const BATCH_OPTIONS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"];
|
|
66
|
+
export const SSH_MISSING = "ssh was not found. Install the OpenSSH client (on Windows: Settings → System → Optional features → OpenSSH Client).";
|
|
67
|
+
export const spawnSsh = (args, opts = {}) => new Promise((resolve) => {
|
|
68
|
+
let stdout = "";
|
|
69
|
+
let stderr = "";
|
|
70
|
+
let settled = false;
|
|
71
|
+
const done = (r) => {
|
|
72
|
+
if (settled)
|
|
73
|
+
return;
|
|
74
|
+
settled = true;
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
resolve(r);
|
|
77
|
+
};
|
|
78
|
+
const child = spawn(sshExecutable(), args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
child.kill("SIGKILL");
|
|
81
|
+
done({ code: null, stdout, stderr, error: `ssh timed out after ${Math.round((opts.timeoutMs ?? 60_000) / 1000)}s` });
|
|
82
|
+
}, opts.timeoutMs ?? 60_000);
|
|
83
|
+
child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
|
|
84
|
+
child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
|
85
|
+
child.stdin.on("error", () => { }); // ssh gone before reading its input: the exit says why
|
|
86
|
+
child.on("error", (e) => done({ code: null, stdout, stderr, error: e.code === "ENOENT" ? SSH_MISSING : e.message }));
|
|
87
|
+
child.on("close", (code) => done({ code, stdout, stderr }));
|
|
88
|
+
child.stdin.end(opts.input ?? "");
|
|
89
|
+
});
|
|
90
|
+
/** The last non-empty line ssh printed, which is where it says what went wrong. */
|
|
91
|
+
function lastLine(text) {
|
|
92
|
+
return text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop() ?? "";
|
|
93
|
+
}
|
|
94
|
+
/** Run a POSIX sh script on `host` and return its stdout. Throws with ssh's own words on failure. */
|
|
95
|
+
export async function runRemoteScript(run, host, script, timeoutMs = 60_000) {
|
|
96
|
+
const problem = hostProblem(host);
|
|
97
|
+
if (problem)
|
|
98
|
+
throw new Error(problem);
|
|
99
|
+
const r = await run([...BATCH_OPTIONS, ...extraSshOptions(), host, "sh", "-s"], { input: script, timeoutMs });
|
|
100
|
+
if (r.error)
|
|
101
|
+
throw new Error(r.error);
|
|
102
|
+
if (r.code === 255)
|
|
103
|
+
throw new SshConnectError(host, lastLine(r.stderr) || "ssh exited with 255");
|
|
104
|
+
if (r.code !== 0)
|
|
105
|
+
throw new Error(`script on ${host} failed (exit ${r.code}): ${lastLine(r.stderr) || "no message"}`);
|
|
106
|
+
return r.stdout;
|
|
107
|
+
}
|
|
108
|
+
/** ssh itself could not connect or authenticate (exit 255), as opposed to our script failing. */
|
|
109
|
+
export class SshConnectError extends Error {
|
|
110
|
+
host;
|
|
111
|
+
detail;
|
|
112
|
+
constructor(host, detail) {
|
|
113
|
+
super(`cannot reach ${host} over ssh: ${detail}`);
|
|
114
|
+
this.host = host;
|
|
115
|
+
this.detail = detail;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const AGENT_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
119
|
+
export function remoteCaPath(home) {
|
|
120
|
+
return `${home}/${REMOTE_DIR}/ca.pem`;
|
|
121
|
+
}
|
|
122
|
+
export function remoteProxyUrl(port) {
|
|
123
|
+
return `http://127.0.0.1:${port}`;
|
|
124
|
+
}
|
|
125
|
+
export async function inspectRemote(run, host) {
|
|
126
|
+
const m = `@@cr-${crypto.randomBytes(6).toString("hex")}`;
|
|
127
|
+
const script = [
|
|
128
|
+
`m='${m}'`,
|
|
129
|
+
`f="$HOME/.claude/settings.json"`,
|
|
130
|
+
`printf '%s home\\n' "$m"; printf '%s\\n' "$HOME"`,
|
|
131
|
+
`printf '%s settings\\n' "$m"`,
|
|
132
|
+
`if [ -e "$f" ]; then printf 'present\\n'; cat "$f" || exit 1; printf '\\n'; else printf 'missing\\n'; fi`,
|
|
133
|
+
`printf '%s sum\\n' "$m"`,
|
|
134
|
+
`if [ -e "$f" ]; then cksum < "$f" || exit 1; fi`,
|
|
135
|
+
`printf '%s backup\\n' "$m"`,
|
|
136
|
+
`if [ -e "$f${SETTINGS_BACKUP_SUFFIX}" ]; then printf 'yes\\n'; else printf 'no\\n'; fi`,
|
|
137
|
+
`printf '%s record\\n' "$m"`,
|
|
138
|
+
`if [ -e "$HOME/${REMOTE_DIR}/agents.json" ]; then cat "$HOME/${REMOTE_DIR}/agents.json"; printf '\\n'; fi`,
|
|
139
|
+
`printf '%s agents\\n' "$m"`,
|
|
140
|
+
`for x in "$HOME/.claude/agents"/*.md; do if [ -e "$x" ]; then basename "$x" .md; fi; done`,
|
|
141
|
+
`printf '%s end\\n' "$m"`,
|
|
142
|
+
`exit 0`,
|
|
143
|
+
"",
|
|
144
|
+
].join("\n");
|
|
145
|
+
const out = await runRemoteScript(run, host, script);
|
|
146
|
+
const sections = new Map();
|
|
147
|
+
let current = null;
|
|
148
|
+
for (const line of out.split("\n")) {
|
|
149
|
+
if (line.startsWith(`${m} `)) {
|
|
150
|
+
current = [];
|
|
151
|
+
sections.set(line.slice(m.length + 1).trim(), current);
|
|
152
|
+
}
|
|
153
|
+
else
|
|
154
|
+
current?.push(line);
|
|
155
|
+
}
|
|
156
|
+
if (!sections.has("end"))
|
|
157
|
+
throw new Error(`unexpected answer from ${host} (is its shell POSIX sh?)`);
|
|
158
|
+
const text = (name) => (sections.get(name) ?? []).join("\n");
|
|
159
|
+
const home = text("home").trim();
|
|
160
|
+
if (!home.startsWith("/"))
|
|
161
|
+
throw new Error(`${host} reported HOME="${home}"; a remote host must be Linux or another POSIX system`);
|
|
162
|
+
const settingsLines = sections.get("settings") ?? [];
|
|
163
|
+
const present = settingsLines[0] === "present";
|
|
164
|
+
let record = null;
|
|
165
|
+
const recordText = text("record").trim();
|
|
166
|
+
if (recordText) {
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(recordText);
|
|
169
|
+
record = {
|
|
170
|
+
agents: Array.isArray(parsed.agents) ? parsed.agents.filter((a) => typeof a === "string" && AGENT_NAME.test(a)) : [],
|
|
171
|
+
...(typeof parsed.proxyUrl === "string" ? { proxyUrl: parsed.proxyUrl } : {}),
|
|
172
|
+
...(typeof parsed.caPath === "string" ? { caPath: parsed.caPath } : {}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
record = { agents: [] }; // ours but unreadable: nothing in it can be trusted as a delete list
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
home,
|
|
181
|
+
settings: present ? settingsLines.slice(1).join("\n") : null,
|
|
182
|
+
settingsSum: present ? text("sum").trim() || null : null,
|
|
183
|
+
backup: text("backup").trim() === "yes",
|
|
184
|
+
record,
|
|
185
|
+
agentFiles: text("agents").split("\n").map((l) => l.trim()).filter(Boolean),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// ---- Writing to the remote ----------------------------------------------------------------
|
|
189
|
+
/** `"$HOME/<rel>"` for a path we built ourselves; refuses anything a shell could read differently. */
|
|
190
|
+
function homePath(rel) {
|
|
191
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(rel) || rel.split("/").includes(".."))
|
|
192
|
+
throw new Error(`refusing remote path "${rel}"`);
|
|
193
|
+
return `"$HOME/${rel}"`;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Lines that replace `$HOME/<rel>` with `content`: a quoted heredoc into a temp file, then `mv`, so
|
|
197
|
+
* a reader sees the old file or the new one. A quoted delimiter means the shell expands nothing in
|
|
198
|
+
* the body. A heredoc always ends in a newline, so content without one gains one.
|
|
199
|
+
*/
|
|
200
|
+
export function writeFileLines(rel, content) {
|
|
201
|
+
const body = content.endsWith("\n") ? content.slice(0, -1) : content;
|
|
202
|
+
let delim = "";
|
|
203
|
+
do
|
|
204
|
+
delim = `CLAUDERIPPLE_EOF_${crypto.randomBytes(8).toString("hex")}`;
|
|
205
|
+
while (body.split("\n").includes(delim));
|
|
206
|
+
const target = homePath(rel);
|
|
207
|
+
const tmp = homePath(`${rel}.clauderipple-tmp`);
|
|
208
|
+
return [`cat > ${tmp} <<'${delim}' || exit 1`, body, delim, `mv -f ${tmp} ${target} || exit 1`];
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Stops the script (exit 3) when the remote settings.json is no longer the file `state` read. Put
|
|
212
|
+
* first in a script that will write it, so a refusal leaves nothing half done.
|
|
213
|
+
*/
|
|
214
|
+
export function settingsGuardLine(state) {
|
|
215
|
+
const f = `"$HOME/.claude/settings.json"`;
|
|
216
|
+
return state.settingsSum === null
|
|
217
|
+
? `if [ -e ${f} ]; then echo "~/.claude/settings.json appeared while it was being edited; run the command again" >&2; exit 3; fi`
|
|
218
|
+
: `if [ "$(cksum < ${f})" != '${state.settingsSum.replace(/'/g, "")}' ]; then echo "~/.claude/settings.json changed while it was being edited; run the command again" >&2; exit 3; fi`;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Lines that write the remote settings.json, but only if it is still the file `state` read: a
|
|
222
|
+
* session or the user may have changed it in between, and that change must not be overwritten.
|
|
223
|
+
* The first time we change it, the original is copied to `settings.json.clauderipple-backup`.
|
|
224
|
+
*/
|
|
225
|
+
export function settingsWriteLines(state, content) {
|
|
226
|
+
const f = `"$HOME/.claude/settings.json"`;
|
|
227
|
+
return [
|
|
228
|
+
`mkdir -p "$HOME/.claude" || exit 1`,
|
|
229
|
+
settingsGuardLine(state),
|
|
230
|
+
`if [ -e ${f} ] && [ ! -e "$HOME/.claude/settings.json${SETTINGS_BACKUP_SUFFIX}" ]; then cp -p ${f} "$HOME/.claude/settings.json${SETTINGS_BACKUP_SUFFIX}" || exit 1; fi`,
|
|
231
|
+
...writeFileLines(".claude/settings.json", content),
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The router's CA certificate — the file the local install points NODE_EXTRA_CA_CERTS at,
|
|
236
|
+
* `<home>/ca.pem` (cli/src/settings.ts via certPaths) — and the agent files this router generated,
|
|
237
|
+
* as `<home>/generated-agents.json` lists them (agents.ts). Only the public certificate: the file
|
|
238
|
+
* is checked to be one, so a key can never travel by mistake.
|
|
239
|
+
*/
|
|
240
|
+
export function readLocalAssets(opts) {
|
|
241
|
+
const caFile = path.join(opts.home, "ca.pem");
|
|
242
|
+
let caPem;
|
|
243
|
+
try {
|
|
244
|
+
caPem = fs.readFileSync(caFile, "utf8");
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
throw new Error(`no CA certificate at ${caFile}; run \`clauderipple install\` first`);
|
|
248
|
+
}
|
|
249
|
+
if (!caPem.includes("-----BEGIN CERTIFICATE-----") || /PRIVATE KEY/.test(caPem))
|
|
250
|
+
throw new Error(`${caFile} is not a certificate; refusing to copy it`);
|
|
251
|
+
let names = [];
|
|
252
|
+
try {
|
|
253
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(opts.home, "generated-agents.json"), "utf8"));
|
|
254
|
+
if (Array.isArray(parsed.agents))
|
|
255
|
+
names = parsed.agents.filter((a) => typeof a === "string" && AGENT_NAME.test(a));
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
/* no generated agents yet */
|
|
259
|
+
}
|
|
260
|
+
const agents = [];
|
|
261
|
+
for (const name of [...names].sort()) {
|
|
262
|
+
try {
|
|
263
|
+
agents.push({ name, content: fs.readFileSync(path.join(opts.agentDir, `${name}.md`), "utf8") });
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
/* listed but gone: the router removes it from the manifest on its next sync */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { caPem, agents };
|
|
270
|
+
}
|
|
271
|
+
/** Changes whenever what a remote should hold changes; a tunnel re-syncs only on a new value. */
|
|
272
|
+
export function assetsFingerprint(a) {
|
|
273
|
+
const h = crypto.createHash("sha256").update(a.caPem);
|
|
274
|
+
for (const agent of a.agents)
|
|
275
|
+
h.update(`\0${agent.name}\0${agent.content}`);
|
|
276
|
+
return h.digest("hex");
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Which agent files to write and remove on the remote. A remote file of the same name that we did
|
|
280
|
+
* not put there is someone's own agent and is left alone; a file we put there whose worker is no
|
|
281
|
+
* longer generated here goes.
|
|
282
|
+
*/
|
|
283
|
+
export function planAgents(local, state) {
|
|
284
|
+
const ours = new Set(state.record?.agents ?? []);
|
|
285
|
+
const present = new Set(state.agentFiles);
|
|
286
|
+
const write = local.filter((n) => !present.has(n) || ours.has(n));
|
|
287
|
+
const skipped = local.filter((n) => present.has(n) && !ours.has(n));
|
|
288
|
+
const remove = [...ours].filter((n) => !local.includes(n));
|
|
289
|
+
return { write, skipped, remove };
|
|
290
|
+
}
|
|
291
|
+
/** Lines that put the CA and the planned agent files on the remote and record what we own. */
|
|
292
|
+
export function assetLines(assets, plan, record) {
|
|
293
|
+
const lines = [`mkdir -p "$HOME/${REMOTE_DIR}" "$HOME/.claude/agents" || exit 1`, ...writeFileLines(`${REMOTE_DIR}/ca.pem`, assets.caPem)];
|
|
294
|
+
for (const name of plan.write) {
|
|
295
|
+
const agent = assets.agents.find((a) => a.name === name);
|
|
296
|
+
if (agent && AGENT_NAME.test(name))
|
|
297
|
+
lines.push(...writeFileLines(`.claude/agents/${name}.md`, agent.content));
|
|
298
|
+
}
|
|
299
|
+
for (const name of plan.remove)
|
|
300
|
+
if (AGENT_NAME.test(name))
|
|
301
|
+
lines.push(`rm -f ${homePath(`.claude/agents/${name}.md`)}`);
|
|
302
|
+
const next = { agents: [...plan.write].sort(), ...record };
|
|
303
|
+
lines.push(...writeFileLines(`${REMOTE_DIR}/agents.json`, JSON.stringify(next, null, 2) + "\n"));
|
|
304
|
+
return lines;
|
|
305
|
+
}
|
|
306
|
+
export function script(lines) {
|
|
307
|
+
return ["umask 077", ...lines, "exit 0", ""].join("\n");
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Bring the CA and agent files on `host` in step with this machine. What the tunnel runs each time
|
|
311
|
+
* it comes up and whenever the router regenerates the agent files; settings.json is not touched
|
|
312
|
+
* (that is `remote add`'s job, once).
|
|
313
|
+
*/
|
|
314
|
+
export async function syncRemoteAssets(run, host, assets) {
|
|
315
|
+
const state = await inspectRemote(run, host);
|
|
316
|
+
const plan = planAgents(assets.agents.map((a) => a.name), state);
|
|
317
|
+
const { agents: _agents, ...kept } = state.record ?? { agents: [] };
|
|
318
|
+
await runRemoteScript(run, host, script(assetLines(assets, plan, kept)));
|
|
319
|
+
return { home: state.home, plan };
|
|
320
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// Reverse SSH tunnels to remote hosts that run Claude Code for Claude Desktop (issue #67).
|
|
2
|
+
//
|
|
3
|
+
// Each configured host gets one long-lived `ssh -N -R 127.0.0.1:<remote port>:127.0.0.1:<proxy
|
|
4
|
+
// port> <host>`, owned by the router because the router is what launchd / the Windows supervisor /
|
|
5
|
+
// systemd already keeps alive. A tunnel that exits is started again with capped backoff; each time
|
|
6
|
+
// one comes up the CA and agent files on that host are brought up to date (remote-host.ts), and
|
|
7
|
+
// again when the router regenerates the agent files.
|
|
8
|
+
//
|
|
9
|
+
// Children are killed when the router exits, and their pids are recorded: a router that died
|
|
10
|
+
// without killing them (SIGKILL) would otherwise leave an orphan holding the remote port, and every
|
|
11
|
+
// new tunnel would then fail `ExitOnForwardFailure` for as long as the orphan lives.
|
|
12
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { BATCH_OPTIONS, SSH_MISSING, extraSshOptions, sshExecutable } from "./remote-host.js";
|
|
16
|
+
/**
|
|
17
|
+
* The tunnel's argv. `-v` is there for one line: `remote forward success for: listen …` is how
|
|
18
|
+
* ssh says the remote port is actually bound (measured, OpenSSH 10.3). Every other debug line is
|
|
19
|
+
* dropped before the log; `ExitOnForwardFailure` makes a forward that cannot bind an exit.
|
|
20
|
+
*/
|
|
21
|
+
export function tunnelArgs(host, port, target) {
|
|
22
|
+
return [
|
|
23
|
+
"-N",
|
|
24
|
+
"-v",
|
|
25
|
+
...BATCH_OPTIONS,
|
|
26
|
+
"-o", "ExitOnForwardFailure=yes",
|
|
27
|
+
"-o", "ServerAliveInterval=15",
|
|
28
|
+
"-o", "ServerAliveCountMax=3",
|
|
29
|
+
"-R", `127.0.0.1:${port}:${target.host}:${target.port}`,
|
|
30
|
+
...extraSshOptions(),
|
|
31
|
+
host,
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
/** The connection target a router listening on `host` is reached at: a wildcard bind means loopback. */
|
|
35
|
+
export function proxyConnectHost(listenHost) {
|
|
36
|
+
return listenHost === "0.0.0.0" || listenHost === "::" || listenHost === "" ? "127.0.0.1" : listenHost;
|
|
37
|
+
}
|
|
38
|
+
function defaultProcessCommand(pid) {
|
|
39
|
+
try {
|
|
40
|
+
if (process.platform === "win32") {
|
|
41
|
+
const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], { encoding: "utf8", windowsHide: true, timeout: 15_000 });
|
|
42
|
+
return out.trim() || null;
|
|
43
|
+
}
|
|
44
|
+
const out = execFileSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 5_000 });
|
|
45
|
+
return out.trim() || null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null; // ps exits 1 when no such process
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export class RemoteTunnels {
|
|
52
|
+
entries = new Map();
|
|
53
|
+
deps;
|
|
54
|
+
backoff;
|
|
55
|
+
closed = false;
|
|
56
|
+
constructor(deps) {
|
|
57
|
+
this.deps = deps;
|
|
58
|
+
this.backoff = deps.backoff ?? { baseMs: 1_000, maxMs: 60_000, stableMs: 60_000 };
|
|
59
|
+
}
|
|
60
|
+
now() {
|
|
61
|
+
return this.deps.now?.() ?? Date.now();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Kill ssh children a previous router left running. Only a pid whose command line is still our
|
|
65
|
+
* tunnel for that host and port is killed: a pid the OS has since given to something else is not.
|
|
66
|
+
*/
|
|
67
|
+
cleanupStale() {
|
|
68
|
+
let records = [];
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(fs.readFileSync(this.deps.pidFile, "utf8"));
|
|
71
|
+
if (Array.isArray(parsed.tunnels))
|
|
72
|
+
records = parsed.tunnels.filter((r) => !!r && typeof r.pid === "number" && typeof r.host === "string" && typeof r.port === "number");
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
const lookup = this.deps.processCommand ?? defaultProcessCommand;
|
|
78
|
+
const kill = this.deps.kill ?? ((pid) => process.kill(pid, "SIGTERM"));
|
|
79
|
+
let killed = 0;
|
|
80
|
+
for (const r of records) {
|
|
81
|
+
if (r.pid === process.pid)
|
|
82
|
+
continue;
|
|
83
|
+
const cmd = lookup(r.pid);
|
|
84
|
+
if (!cmd || !/\bssh(\.exe)?\b/i.test(cmd) || !cmd.includes(`127.0.0.1:${r.port}:`) || !cmd.includes(r.host))
|
|
85
|
+
continue;
|
|
86
|
+
try {
|
|
87
|
+
kill(r.pid);
|
|
88
|
+
killed++;
|
|
89
|
+
this.deps.log.info(`remote ${r.host}: killed tunnel pid ${r.pid} left by a previous router`);
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
this.deps.log.warn(`remote ${r.host}: cannot kill stale tunnel pid ${r.pid}: ${e.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
this.writePids();
|
|
96
|
+
return killed;
|
|
97
|
+
}
|
|
98
|
+
/** Start tunnels for new hosts, stop removed ones, restart one whose port or target changed. */
|
|
99
|
+
reconcile(hosts) {
|
|
100
|
+
if (this.closed)
|
|
101
|
+
return;
|
|
102
|
+
const target = this.deps.proxyTarget();
|
|
103
|
+
const wanted = new Map(hosts.map((h) => [h.host, h]));
|
|
104
|
+
for (const [host, entry] of this.entries) {
|
|
105
|
+
const want = wanted.get(host);
|
|
106
|
+
if (!want || want.port !== entry.port || entry.target.host !== target.host || entry.target.port !== target.port) {
|
|
107
|
+
this.stop(entry);
|
|
108
|
+
this.entries.delete(host);
|
|
109
|
+
this.deps.log.info(`remote ${host}: tunnel stopped${want ? " (port changed)" : " (host removed)"}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const h of hosts) {
|
|
113
|
+
if (this.entries.has(h.host))
|
|
114
|
+
continue;
|
|
115
|
+
const entry = {
|
|
116
|
+
host: h.host, port: h.port, target, child: null, state: "starting", since: this.now(), upAt: null, lastError: null,
|
|
117
|
+
attempt: 0, restarts: 0, timer: null, settle: null, stopped: false, synced: null, syncedPrint: null, syncing: false,
|
|
118
|
+
};
|
|
119
|
+
this.entries.set(h.host, entry);
|
|
120
|
+
this.start(entry);
|
|
121
|
+
}
|
|
122
|
+
this.writePids();
|
|
123
|
+
}
|
|
124
|
+
status() {
|
|
125
|
+
return [...this.entries.values()].map((e) => ({
|
|
126
|
+
host: e.host,
|
|
127
|
+
port: e.port,
|
|
128
|
+
state: e.state,
|
|
129
|
+
since: new Date(e.since).toISOString(),
|
|
130
|
+
lastError: e.lastError,
|
|
131
|
+
pid: e.child?.pid ?? null,
|
|
132
|
+
restarts: e.restarts,
|
|
133
|
+
synced: e.synced === null ? null : new Date(e.synced).toISOString(),
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
/** The agent files or the CA changed here: copy them to every host whose tunnel is up. */
|
|
137
|
+
assetsChanged() {
|
|
138
|
+
if (!this.deps.fingerprint)
|
|
139
|
+
return;
|
|
140
|
+
const print = this.deps.fingerprint();
|
|
141
|
+
for (const e of this.entries.values())
|
|
142
|
+
if (e.state === "up" && e.syncedPrint !== print)
|
|
143
|
+
void this.sync(e);
|
|
144
|
+
}
|
|
145
|
+
/** Kill every tunnel. Synchronous, so it can run from an `exit` handler. */
|
|
146
|
+
stopAll() {
|
|
147
|
+
this.closed = true;
|
|
148
|
+
for (const e of this.entries.values())
|
|
149
|
+
this.stop(e);
|
|
150
|
+
this.entries.clear();
|
|
151
|
+
this.writePids();
|
|
152
|
+
}
|
|
153
|
+
setState(e, state) {
|
|
154
|
+
if (e.state !== state)
|
|
155
|
+
e.since = this.now();
|
|
156
|
+
e.state = state;
|
|
157
|
+
}
|
|
158
|
+
start(e) {
|
|
159
|
+
if (e.stopped || this.closed)
|
|
160
|
+
return;
|
|
161
|
+
e.timer = null;
|
|
162
|
+
this.setState(e, "starting");
|
|
163
|
+
let child;
|
|
164
|
+
try {
|
|
165
|
+
child = (this.deps.spawn ?? ((cmd, args) => spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"], windowsHide: true })))(sshExecutable(), tunnelArgs(e.host, e.port, e.target));
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
this.gone(e, null, err.message);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
e.child = child;
|
|
172
|
+
this.writePids();
|
|
173
|
+
this.deps.log.info(`remote ${e.host}: starting tunnel 127.0.0.1:${e.port} on the host → ${e.target.host}:${e.target.port}${child.pid ? ` (pid ${child.pid})` : ""}`);
|
|
174
|
+
let buffered = "";
|
|
175
|
+
let said = null; // this child's own last word, not an earlier one's
|
|
176
|
+
child.stderr?.on("data", (d) => {
|
|
177
|
+
buffered += d.toString();
|
|
178
|
+
let i;
|
|
179
|
+
while ((i = buffered.indexOf("\n")) >= 0) {
|
|
180
|
+
const line = buffered.slice(0, i).replace(/\r$/, "");
|
|
181
|
+
buffered = buffered.slice(i + 1);
|
|
182
|
+
if (e.child === child)
|
|
183
|
+
said = this.stderrLine(e, line) ?? said;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
child.on("error", (err) => {
|
|
187
|
+
if (e.child !== child)
|
|
188
|
+
return;
|
|
189
|
+
this.gone(e, child, err.code === "ENOENT" ? SSH_MISSING : err.message, err.code === "ENOENT");
|
|
190
|
+
});
|
|
191
|
+
// `close`, not `exit`: it comes after stderr has been read to the end, so the reason ssh gave
|
|
192
|
+
// for leaving is in `said` by then.
|
|
193
|
+
child.on("close", (code, signal) => {
|
|
194
|
+
if (e.child !== child)
|
|
195
|
+
return;
|
|
196
|
+
this.gone(e, child, said ?? `ssh exited (${signal ?? `code ${code}`})`);
|
|
197
|
+
});
|
|
198
|
+
const settleMs = this.deps.settleMs ?? 15_000;
|
|
199
|
+
e.settle = setTimeout(() => {
|
|
200
|
+
if (e.child === child && e.state === "starting")
|
|
201
|
+
this.up(e);
|
|
202
|
+
}, settleMs);
|
|
203
|
+
e.settle.unref?.();
|
|
204
|
+
}
|
|
205
|
+
/** Returns the line when it is something ssh said to a person (an error, a warning). */
|
|
206
|
+
stderrLine(e, line) {
|
|
207
|
+
if (!line.trim())
|
|
208
|
+
return null;
|
|
209
|
+
if (/^debug\d?: remote forward success for: listen/.test(line)) {
|
|
210
|
+
this.up(e);
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
if (/^debug\d?:/.test(line))
|
|
214
|
+
return null; // `-v` noise
|
|
215
|
+
// `-v` also lets through ssh's verbose-level notices, which carry no prefix (measured: the
|
|
216
|
+
// "Authenticated to … using "publickey"" line on every connect). They are not errors.
|
|
217
|
+
if (/^(Authenticated to |Transferred: |Bytes per second|Warning: Permanently added )/.test(line)) {
|
|
218
|
+
this.deps.log.info(`remote ${e.host}: ${line.trim()}`);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
e.lastError = line.trim();
|
|
222
|
+
this.deps.log.warn(`remote ${e.host}: ${line.trim()}`);
|
|
223
|
+
return line.trim();
|
|
224
|
+
}
|
|
225
|
+
up(e) {
|
|
226
|
+
if (e.state === "up")
|
|
227
|
+
return;
|
|
228
|
+
if (e.settle)
|
|
229
|
+
clearTimeout(e.settle);
|
|
230
|
+
e.settle = null;
|
|
231
|
+
this.setState(e, "up");
|
|
232
|
+
e.upAt = this.now();
|
|
233
|
+
e.lastError = null;
|
|
234
|
+
this.deps.log.info(`remote ${e.host}: tunnel up (127.0.0.1:${e.port} on the host)`);
|
|
235
|
+
// Each (re)start copies again: the host may have lost or changed the files while we were away.
|
|
236
|
+
e.syncedPrint = null;
|
|
237
|
+
void this.sync(e);
|
|
238
|
+
}
|
|
239
|
+
async sync(e) {
|
|
240
|
+
if (!this.deps.sync || e.syncing)
|
|
241
|
+
return;
|
|
242
|
+
e.syncing = true;
|
|
243
|
+
const print = this.deps.fingerprint?.() ?? null;
|
|
244
|
+
try {
|
|
245
|
+
await this.deps.sync(e.host);
|
|
246
|
+
e.synced = this.now();
|
|
247
|
+
e.syncedPrint = print;
|
|
248
|
+
this.deps.log.info(`remote ${e.host}: CA and agent files synced`);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
this.deps.log.warn(`remote ${e.host}: sync failed: ${err.message}`);
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
e.syncing = false;
|
|
255
|
+
}
|
|
256
|
+
// Something changed while this one ran: catch up once.
|
|
257
|
+
if (this.deps.fingerprint && e.state === "up" && e.syncedPrint !== null && this.deps.fingerprint() !== e.syncedPrint)
|
|
258
|
+
void this.sync(e);
|
|
259
|
+
}
|
|
260
|
+
gone(e, child, reason, sshMissing = false) {
|
|
261
|
+
if (child && e.child !== child)
|
|
262
|
+
return;
|
|
263
|
+
e.child = null;
|
|
264
|
+
if (e.settle)
|
|
265
|
+
clearTimeout(e.settle);
|
|
266
|
+
e.settle = null;
|
|
267
|
+
this.writePids();
|
|
268
|
+
if (e.stopped || this.closed) {
|
|
269
|
+
this.setState(e, "down");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (e.upAt !== null && this.now() - e.upAt >= this.backoff.stableMs)
|
|
273
|
+
e.attempt = 0;
|
|
274
|
+
e.upAt = null;
|
|
275
|
+
e.lastError = reason;
|
|
276
|
+
const delay = sshMissing ? this.backoff.maxMs : Math.min(this.backoff.maxMs, this.backoff.baseMs * 2 ** e.attempt);
|
|
277
|
+
e.attempt++;
|
|
278
|
+
this.setState(e, sshMissing ? "down" : "backoff");
|
|
279
|
+
this.deps.log.warn(`remote ${e.host}: tunnel ${sshMissing ? "cannot start" : "down"}: ${reason}; retrying in ${Math.round(delay / 1000)}s`);
|
|
280
|
+
e.timer = setTimeout(() => {
|
|
281
|
+
e.restarts++;
|
|
282
|
+
this.start(e);
|
|
283
|
+
}, delay);
|
|
284
|
+
e.timer.unref?.();
|
|
285
|
+
}
|
|
286
|
+
stop(e) {
|
|
287
|
+
e.stopped = true;
|
|
288
|
+
if (e.timer)
|
|
289
|
+
clearTimeout(e.timer);
|
|
290
|
+
if (e.settle)
|
|
291
|
+
clearTimeout(e.settle);
|
|
292
|
+
e.timer = e.settle = null;
|
|
293
|
+
const child = e.child;
|
|
294
|
+
e.child = null;
|
|
295
|
+
this.setState(e, "down");
|
|
296
|
+
if (child) {
|
|
297
|
+
try {
|
|
298
|
+
child.kill("SIGTERM");
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
/* already gone */
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
writePids() {
|
|
306
|
+
const tunnels = [];
|
|
307
|
+
for (const e of this.entries.values())
|
|
308
|
+
if (e.child?.pid)
|
|
309
|
+
tunnels.push({ pid: e.child.pid, host: e.host, port: e.port });
|
|
310
|
+
if (tunnels.length === 0 && !fs.existsSync(this.deps.pidFile))
|
|
311
|
+
return; // a router with no remote hosts leaves no file
|
|
312
|
+
try {
|
|
313
|
+
fs.mkdirSync(path.dirname(this.deps.pidFile), { recursive: true });
|
|
314
|
+
fs.writeFileSync(this.deps.pidFile, JSON.stringify({ tunnels }, null, 2) + "\n");
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
this.deps.log.warn(`remote: cannot record tunnel pids in ${this.deps.pidFile}: ${err.message}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|