herdr-remote 0.2.20 → 0.2.22
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/README.md +12 -2
- package/README.zh-CN.md +11 -1
- package/dist/cli.js +23 -4
- package/dist/config.js +8 -1
- package/dist/herdr-api.js +5 -3
- package/dist/herdr-command.js +32 -15
- package/dist/herdr-plugin.js +5 -1
- package/dist/herdr-server.js +3 -2
- package/dist/i18n/en.js +20 -0
- package/dist/i18n/zh.js +20 -0
- package/dist/keepalive/detached.js +13 -3
- package/dist/keepalive/index.js +16 -8
- package/dist/keepalive/launchd.js +4 -4
- package/dist/keepalive/systemd.js +8 -4
- package/dist/keepalive/windows-service-setup.js +129 -0
- package/dist/keepalive/windows-service.js +272 -0
- package/dist/lifecycle.js +15 -1
- package/dist/net-interfaces.js +15 -6
- package/dist/paths.js +16 -4
- package/dist/pty-session.js +10 -2
- package/dist/relay-updater.js +81 -0
- package/dist/service.js +2 -0
- package/dist/socket-discovery.js +15 -3
- package/dist/supervisor.js +1 -0
- package/dist/terminal-font/deps.js +11 -1
- package/dist/terminal-font/files.js +13 -10
- package/dist/terminal-font/readers.js +60 -3
- package/dist/terminal-font/terminals.js +3 -0
- package/dist/terminal-palette.js +78 -18
- package/dist/tui/App.js +34 -8
- package/dist/tui/api.js +6 -4
- package/dist/tui/screens/About.js +30 -15
- package/dist/tui/screens/Herdr.js +5 -4
- package/dist/tui/screens/Keepalive.js +25 -14
- package/dist/tui/screens/Pair.js +1 -1
- package/dist/tui/screens/Relay.js +9 -8
- package/dist/tui/screens/Services.js +4 -4
- package/dist/tui/screens/Wizard.js +11 -5
- package/dist/tui/screens/useRelayUpdate.js +122 -0
- package/dist/updater/npm.js +62 -0
- package/dist/updater.js +8 -37
- package/herdr-plugin.toml +2 -2
- package/package.json +2 -1
- package/vendor/winsw/LICENSE +21 -0
- package/vendor/winsw/README.md +9 -0
- package/vendor/winsw/WinSW.NET461.exe +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { classifyStartResult, currentServiceAccount, encodePowerShellCommand, escapePowerShellSingleQuoted, startService, waitForState, windowsServicePaths, serviceState, windowsService, } from './windows-service.js';
|
|
4
|
+
const MAX_PASSWORD_ATTEMPTS = 3;
|
|
5
|
+
export function renderSetServiceAccountScript({ id, account }) {
|
|
6
|
+
const safeId = escapePowerShellSingleQuoted(id);
|
|
7
|
+
const safeAccount = escapePowerShellSingleQuoted(account);
|
|
8
|
+
return `$ErrorActionPreference = 'Stop'
|
|
9
|
+
$password = [Console]::In.ReadLine()
|
|
10
|
+
$service = Get-CimInstance -ClassName Win32_Service -Filter "Name='${safeId}'"
|
|
11
|
+
$result = Invoke-CimMethod -InputObject $service -MethodName Change -Arguments @{ StartName = '${safeAccount}'; StartPassword = $password }
|
|
12
|
+
exit [int]$result.ReturnValue`;
|
|
13
|
+
}
|
|
14
|
+
function setServiceAccount(id, account, password) {
|
|
15
|
+
const script = renderSetServiceAccountScript({ id, account });
|
|
16
|
+
// The password travels through stdin only; it is not part of PowerShell arguments or a file.
|
|
17
|
+
return spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodePowerShellCommand(script)], { input: `${password}\n`, encoding: 'utf8', windowsHide: true });
|
|
18
|
+
}
|
|
19
|
+
/** Read a password with `*` feedback and remove ESC sequences such as arrow keys. */
|
|
20
|
+
function promptSecret(prompt) {
|
|
21
|
+
const input = process.stdin;
|
|
22
|
+
process.stdout.write(prompt);
|
|
23
|
+
input.setRawMode(true);
|
|
24
|
+
input.resume();
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
let password = '';
|
|
27
|
+
const finish = (value) => {
|
|
28
|
+
input.removeListener('data', onData);
|
|
29
|
+
input.setRawMode(false);
|
|
30
|
+
input.pause();
|
|
31
|
+
process.stdout.write('\n');
|
|
32
|
+
resolve(value);
|
|
33
|
+
};
|
|
34
|
+
const onData = (chunk) => {
|
|
35
|
+
const clean = String(chunk).replace(/\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|O.|.)/g, '');
|
|
36
|
+
for (const character of clean) {
|
|
37
|
+
if (character === '\r' || character === '\n')
|
|
38
|
+
return finish(password);
|
|
39
|
+
if (character === '\u0003')
|
|
40
|
+
return finish(null);
|
|
41
|
+
if (character === '\u007f' || character === '\b') {
|
|
42
|
+
if (password.length > 0) {
|
|
43
|
+
password = Array.from(password).slice(0, -1).join('');
|
|
44
|
+
process.stdout.write('\b \b');
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const code = character.codePointAt(0) || 0;
|
|
49
|
+
if (code < 0x20 || (code >= 0x7f && code <= 0x9f))
|
|
50
|
+
continue;
|
|
51
|
+
password += character;
|
|
52
|
+
process.stdout.write('*');
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
input.on('data', onData);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function failure(label, result) {
|
|
59
|
+
const output = String(result.stdout || result.stderr || '').trim();
|
|
60
|
+
return new Error(`${label} failed (${String(result.status)}): ${output}`);
|
|
61
|
+
}
|
|
62
|
+
/** Complete service installation inside the elevated console. */
|
|
63
|
+
export async function completeServiceInstall(t) {
|
|
64
|
+
try {
|
|
65
|
+
const paths = windowsServicePaths();
|
|
66
|
+
// A different elevated account resolves a different state directory
|
|
67
|
+
// and cannot see the XML written before elevation.
|
|
68
|
+
if (!fs.existsSync(paths.unitPath)) {
|
|
69
|
+
process.stdout.write(`${t('keepalive.wrongAccount')}\n`);
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
if (serviceState() !== 'NonExistent') {
|
|
73
|
+
windowsService.stop();
|
|
74
|
+
waitForState('Stopped');
|
|
75
|
+
const removed = spawnSync(paths.exePath, ['uninstall'], {
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
});
|
|
79
|
+
if (removed.error || removed.status !== 0)
|
|
80
|
+
throw failure('WinSW uninstall', removed);
|
|
81
|
+
}
|
|
82
|
+
const installed = spawnSync(paths.exePath, ['install'], {
|
|
83
|
+
stdio: 'inherit',
|
|
84
|
+
windowsHide: true,
|
|
85
|
+
});
|
|
86
|
+
if (installed.error || installed.status !== 0)
|
|
87
|
+
throw failure('WinSW install', installed);
|
|
88
|
+
const { domain, user } = currentServiceAccount();
|
|
89
|
+
const account = `${domain}\\${user}`;
|
|
90
|
+
for (let attempt = 0; attempt < MAX_PASSWORD_ATTEMPTS; attempt += 1) {
|
|
91
|
+
const password = await promptSecret(t('keepalive.passwordPrompt', { account }));
|
|
92
|
+
if (password === null)
|
|
93
|
+
return 1;
|
|
94
|
+
const changed = setServiceAccount(paths.id, account, password);
|
|
95
|
+
if (changed.error || changed.status !== 0)
|
|
96
|
+
throw failure('Setting the Windows service account', changed);
|
|
97
|
+
const started = classifyStartResult(startService());
|
|
98
|
+
if (started === 'ok') {
|
|
99
|
+
process.stdout.write(`${t('keepalive.serviceInstalled', { id: paths.id })}\n`);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
if (started === 'logon-failed') {
|
|
103
|
+
process.stdout.write(`${t('keepalive.passwordRejected')}\n`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
process.stdout.write(`${started.message}\n`);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
process.stdout.write(`${error.message}\n`);
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
process.stdout.write(`${t('keepalive.pressEnter')}\n`);
|
|
117
|
+
process.stdin.resume();
|
|
118
|
+
await new Promise((resolve) => {
|
|
119
|
+
const onData = (chunk) => {
|
|
120
|
+
if (!/[\r\n]/.test(String(chunk)))
|
|
121
|
+
return;
|
|
122
|
+
process.stdin.removeListener('data', onData);
|
|
123
|
+
process.stdin.pause();
|
|
124
|
+
resolve();
|
|
125
|
+
};
|
|
126
|
+
process.stdin.on('data', onData);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { configDir, PACKAGE_ROOT, stateDir } from '../paths.js';
|
|
7
|
+
import { cliEntryPoint, serviceEnvironment } from './environment.js';
|
|
8
|
+
import { escapeXml } from './launchd.js';
|
|
9
|
+
const DEFAULT_STATE_TIMEOUT_MS = 20_000;
|
|
10
|
+
const STATE_POLL_INTERVAL_MS = 250;
|
|
11
|
+
/** The service name is machine-wide, so include the account to isolate each user's service. */
|
|
12
|
+
export function serviceId(username) {
|
|
13
|
+
return `herdr-remote-${username.replace(/[^A-Za-z0-9._-]/g, '_')}`;
|
|
14
|
+
}
|
|
15
|
+
export function currentServiceAccount() {
|
|
16
|
+
return {
|
|
17
|
+
domain: process.env.USERDOMAIN || '',
|
|
18
|
+
user: process.env.USERNAME || os.userInfo().username,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Keep WinSW outside the npm package so updates do not touch a running executable. */
|
|
22
|
+
export function windowsServicePaths() {
|
|
23
|
+
const id = serviceId(currentServiceAccount().user);
|
|
24
|
+
const directory = path.join(stateDir(), 'service');
|
|
25
|
+
return {
|
|
26
|
+
id,
|
|
27
|
+
directory,
|
|
28
|
+
exePath: path.join(directory, `${id}.exe`),
|
|
29
|
+
unitPath: path.join(directory, `${id}.xml`),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function lastNonemptyLine(output) {
|
|
33
|
+
return output.trim().split(/\r?\n/).pop()?.trim();
|
|
34
|
+
}
|
|
35
|
+
export function parseWhoamiSid(output) {
|
|
36
|
+
const sid = lastNonemptyLine(output)?.match(/"([^"]+)"\s*$/)?.[1];
|
|
37
|
+
return sid && /^S-\d(?:-\d+)+$/.test(sid) ? sid : null;
|
|
38
|
+
}
|
|
39
|
+
export function parseWinswState(output) {
|
|
40
|
+
const state = lastNonemptyLine(output);
|
|
41
|
+
return state === 'Started' || state === 'Stopped' || state === 'NonExistent'
|
|
42
|
+
? state
|
|
43
|
+
: 'NonExistent';
|
|
44
|
+
}
|
|
45
|
+
export function windowsServiceSddl(userSid) {
|
|
46
|
+
// SYSTEM, Administrators, interactive users and service users keep the default service DACL.
|
|
47
|
+
// The last ACE lets ordinary plugin hooks and the TUI start or stop this user's service.
|
|
48
|
+
const accessEntries = [
|
|
49
|
+
'(A;;CCLCSWRPWPDTLOCRRC;;;SY)',
|
|
50
|
+
'(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)',
|
|
51
|
+
'(A;;CCLCSWLOCRRC;;;IU)',
|
|
52
|
+
'(A;;CCLCSWLOCRRC;;;SU)',
|
|
53
|
+
`(A;;CCLCSWRPWPDTLOCRRC;;;${userSid})`,
|
|
54
|
+
];
|
|
55
|
+
return 'D:' + accessEntries.join('');
|
|
56
|
+
}
|
|
57
|
+
export function renderWinswConfig(options) {
|
|
58
|
+
const { id, user, domain, userSid, nodePath, entryPoint, workingDirectory, environment } = options;
|
|
59
|
+
const environmentLines = Object.entries(environment).map(([key, value]) => ` <env name="${escapeXml(key)}" value="${escapeXml(value)}"/>`);
|
|
60
|
+
// Children write their own logs; WinSW captures the supervisor in supervisor.out.log.
|
|
61
|
+
return `<service>
|
|
62
|
+
<id>${escapeXml(id)}</id>
|
|
63
|
+
<name>${escapeXml(`Herdr Remote (${user})`)}</name>
|
|
64
|
+
<description>${escapeXml(`Browser access to Herdr workspaces, running as ${domain}\\${user}.`)}</description>
|
|
65
|
+
<executable>${escapeXml(nodePath)}</executable>
|
|
66
|
+
<arguments>${escapeXml(`"${entryPoint}" run --daemon`)}</arguments>
|
|
67
|
+
<workingdirectory>${escapeXml(workingDirectory)}</workingdirectory>
|
|
68
|
+
<startmode>Automatic</startmode>
|
|
69
|
+
<delayedAutoStart/>
|
|
70
|
+
<onfailure action="restart" delay="3 sec"/>
|
|
71
|
+
<resetfailure>1 hour</resetfailure>
|
|
72
|
+
<stoptimeout>15 sec</stoptimeout>
|
|
73
|
+
<logpath>${escapeXml(workingDirectory)}</logpath>
|
|
74
|
+
<logname>supervisor</logname>
|
|
75
|
+
<log mode="append"/>
|
|
76
|
+
${environmentLines.join('\n')}
|
|
77
|
+
<serviceaccount>
|
|
78
|
+
<domain>${escapeXml(domain)}</domain>
|
|
79
|
+
<user>${escapeXml(user)}</user>
|
|
80
|
+
<allowservicelogon>true</allowservicelogon>
|
|
81
|
+
</serviceaccount>
|
|
82
|
+
<securityDescriptor>${escapeXml(windowsServiceSddl(userSid))}</securityDescriptor>
|
|
83
|
+
</service>
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
export function escapePowerShellSingleQuoted(value) {
|
|
87
|
+
return value.replace(/'/g, "''");
|
|
88
|
+
}
|
|
89
|
+
export function renderElevationScript({ nodePath, entryPoint }) {
|
|
90
|
+
const executable = escapePowerShellSingleQuoted(nodePath);
|
|
91
|
+
const scriptPath = escapePowerShellSingleQuoted(entryPoint);
|
|
92
|
+
return `$ErrorActionPreference = 'Stop'
|
|
93
|
+
try {
|
|
94
|
+
$process = Start-Process -FilePath '${executable}' -ArgumentList '"${scriptPath}" keepalive install --elevated' -Verb RunAs -Wait -PassThru
|
|
95
|
+
} catch {
|
|
96
|
+
exit 1223
|
|
97
|
+
}
|
|
98
|
+
exit $process.ExitCode`;
|
|
99
|
+
}
|
|
100
|
+
export function encodePowerShellCommand(script) {
|
|
101
|
+
return Buffer.from(script, 'utf16le').toString('base64');
|
|
102
|
+
}
|
|
103
|
+
function text(value) {
|
|
104
|
+
return value ? String(value).trim() : '';
|
|
105
|
+
}
|
|
106
|
+
function runSc(command) {
|
|
107
|
+
const result = spawnSync('sc.exe', [command, windowsServicePaths().id], {
|
|
108
|
+
encoding: 'utf8',
|
|
109
|
+
windowsHide: true,
|
|
110
|
+
});
|
|
111
|
+
return { code: result.status, stdout: text(result.stdout), stderr: text(result.stderr) };
|
|
112
|
+
}
|
|
113
|
+
function scFailure(command, result) {
|
|
114
|
+
const output = result.stdout || result.stderr;
|
|
115
|
+
return new Error(`sc.exe ${command} failed (${result.code}): ${output}`);
|
|
116
|
+
}
|
|
117
|
+
/** sc.exe returns Win32 codes: 1056 means already running and 1069 means service logon failed. */
|
|
118
|
+
export function classifyStartResult(result) {
|
|
119
|
+
if (result.code === 0 || result.code === 1056)
|
|
120
|
+
return 'ok';
|
|
121
|
+
if (result.code === 1069)
|
|
122
|
+
return 'logon-failed';
|
|
123
|
+
return scFailure('start', result);
|
|
124
|
+
}
|
|
125
|
+
export function startService() {
|
|
126
|
+
return runSc('start');
|
|
127
|
+
}
|
|
128
|
+
export function stopService() {
|
|
129
|
+
return runSc('stop');
|
|
130
|
+
}
|
|
131
|
+
export function serviceState() {
|
|
132
|
+
const { exePath } = windowsServicePaths();
|
|
133
|
+
if (!fs.existsSync(exePath))
|
|
134
|
+
return 'NonExistent';
|
|
135
|
+
const result = spawnSync(exePath, ['status'], { encoding: 'utf8', windowsHide: true });
|
|
136
|
+
return result.error || result.status !== 0 ? 'NonExistent' : parseWinswState(text(result.stdout));
|
|
137
|
+
}
|
|
138
|
+
export function waitForState(target, timeoutMs = DEFAULT_STATE_TIMEOUT_MS) {
|
|
139
|
+
const deadlineMs = Date.now() + timeoutMs;
|
|
140
|
+
while (serviceState() !== target) {
|
|
141
|
+
if (Date.now() >= deadlineMs)
|
|
142
|
+
throw new Error(`Windows service did not reach ${target} within ${timeoutMs} ms`);
|
|
143
|
+
// Lifecycle start/restart is synchronous, so this wait must not yield to an event loop.
|
|
144
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, STATE_POLL_INTERVAL_MS);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function stop() {
|
|
148
|
+
const result = stopService();
|
|
149
|
+
if (result.code !== 0 && result.code !== 1062 && result.code !== 1060)
|
|
150
|
+
throw scFailure('stop', result);
|
|
151
|
+
}
|
|
152
|
+
function start() {
|
|
153
|
+
const commandResult = startService();
|
|
154
|
+
const result = classifyStartResult(commandResult);
|
|
155
|
+
if (result === 'logon-failed') {
|
|
156
|
+
const output = commandResult.stdout || commandResult.stderr;
|
|
157
|
+
throw new Error(`Windows service logon failed (1069): ${output}`);
|
|
158
|
+
}
|
|
159
|
+
if (result instanceof Error)
|
|
160
|
+
throw result;
|
|
161
|
+
return { ok: true };
|
|
162
|
+
}
|
|
163
|
+
function sha256(filePath) {
|
|
164
|
+
return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
|
165
|
+
}
|
|
166
|
+
async function prepare() {
|
|
167
|
+
const paths = windowsServicePaths();
|
|
168
|
+
await fs.promises.mkdir(paths.directory, { recursive: true });
|
|
169
|
+
const source = path.join(PACKAGE_ROOT, 'vendor', 'winsw', 'WinSW.NET461.exe');
|
|
170
|
+
if (!fs.existsSync(paths.exePath) || sha256(paths.exePath) !== sha256(source)) {
|
|
171
|
+
try {
|
|
172
|
+
await fs.promises.copyFile(source, paths.exePath);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (!['EBUSY', 'EPERM', 'EACCES'].includes(error.code || ''))
|
|
176
|
+
throw error;
|
|
177
|
+
throw new Error(`Could not replace ${paths.exePath}; stop the Windows service first.`, {
|
|
178
|
+
cause: error,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// A service may not receive reliable APPDATA, so pin both canonical directories.
|
|
183
|
+
const environment = {
|
|
184
|
+
...serviceEnvironment(),
|
|
185
|
+
HERDR_REMOTE_CONFIG_DIR: configDir(),
|
|
186
|
+
HERDR_REMOTE_STATE_DIR: stateDir(),
|
|
187
|
+
HERDR_REMOTE_SERVICE: '1',
|
|
188
|
+
};
|
|
189
|
+
const result = spawnSync('whoami', ['/user', '/fo', 'csv', '/nh'], {
|
|
190
|
+
encoding: 'utf8',
|
|
191
|
+
windowsHide: true,
|
|
192
|
+
});
|
|
193
|
+
const userSid = !result.error && result.status === 0 ? parseWhoamiSid(text(result.stdout)) : null;
|
|
194
|
+
if (!userSid)
|
|
195
|
+
throw new Error(`Could not read the current Windows user SID: ${text(result.stderr)}`);
|
|
196
|
+
const { user, domain } = currentServiceAccount();
|
|
197
|
+
await fs.promises.writeFile(paths.unitPath, renderWinswConfig({
|
|
198
|
+
id: paths.id,
|
|
199
|
+
user,
|
|
200
|
+
domain,
|
|
201
|
+
userSid,
|
|
202
|
+
nodePath: process.execPath,
|
|
203
|
+
entryPoint: cliEntryPoint(),
|
|
204
|
+
workingDirectory: stateDir(),
|
|
205
|
+
environment,
|
|
206
|
+
}), 'utf8');
|
|
207
|
+
return paths;
|
|
208
|
+
}
|
|
209
|
+
function waitForChild(child) {
|
|
210
|
+
return new Promise((resolve, reject) => {
|
|
211
|
+
child.once('error', reject);
|
|
212
|
+
child.once('close', (code) => resolve(code ?? 1));
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/** The password is entered only in the elevated console, so wait for that window to finish. */
|
|
216
|
+
async function install() {
|
|
217
|
+
const paths = await prepare();
|
|
218
|
+
const script = renderElevationScript({ nodePath: process.execPath, entryPoint: cliEntryPoint() });
|
|
219
|
+
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodePowerShellCommand(script)], { windowsHide: true, stdio: 'ignore' });
|
|
220
|
+
const exitCode = await waitForChild(child);
|
|
221
|
+
if (exitCode === 1223) {
|
|
222
|
+
const error = new Error('Administrator rights were not granted; nothing was installed.');
|
|
223
|
+
error.code = 'ELEVATION_CANCELLED';
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
if (exitCode !== 0)
|
|
227
|
+
throw new Error(`Windows service installation did not complete (exit code ${exitCode}).`);
|
|
228
|
+
return { ok: true, unitPath: paths.unitPath, state: serviceState() };
|
|
229
|
+
}
|
|
230
|
+
function status() {
|
|
231
|
+
const paths = windowsServicePaths();
|
|
232
|
+
const state = serviceState();
|
|
233
|
+
const installed = state !== 'NonExistent';
|
|
234
|
+
return {
|
|
235
|
+
manager: 'windows-service',
|
|
236
|
+
installed,
|
|
237
|
+
active: state === 'Started',
|
|
238
|
+
enabled: installed,
|
|
239
|
+
unitPath: paths.unitPath,
|
|
240
|
+
state,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** WinSW requests elevation itself, so stopping first avoids a second UAC prompt. */
|
|
244
|
+
async function uninstall() {
|
|
245
|
+
const paths = windowsServicePaths();
|
|
246
|
+
stop();
|
|
247
|
+
waitForState('Stopped');
|
|
248
|
+
const result = spawnSync(paths.exePath, ['uninstall'], { windowsHide: true });
|
|
249
|
+
if (result.error || result.status !== 0)
|
|
250
|
+
throw new Error(`WinSW uninstall failed with exit code ${String(result.status)}: ${text(result.stdout)}`);
|
|
251
|
+
if (serviceState() !== 'NonExistent')
|
|
252
|
+
throw new Error(`Windows service still exists: ${paths.id}`);
|
|
253
|
+
fs.rmSync(paths.directory, { recursive: true, force: true });
|
|
254
|
+
return { ok: true, unitPath: paths.unitPath };
|
|
255
|
+
}
|
|
256
|
+
function restart() {
|
|
257
|
+
stop();
|
|
258
|
+
waitForState('Stopped');
|
|
259
|
+
return start();
|
|
260
|
+
}
|
|
261
|
+
function logsHint() {
|
|
262
|
+
return `Get-Content -Wait -Tail 40 "${path.join(stateDir(), 'supervisor.out.log')}"`;
|
|
263
|
+
}
|
|
264
|
+
export const windowsService = {
|
|
265
|
+
name: 'windows-service',
|
|
266
|
+
status,
|
|
267
|
+
install,
|
|
268
|
+
uninstall,
|
|
269
|
+
restart,
|
|
270
|
+
stop,
|
|
271
|
+
logsHint,
|
|
272
|
+
};
|
package/dist/lifecycle.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// here so the managed and unmanaged paths behave the same from the outside.
|
|
7
7
|
import { loadConfig } from './config.js';
|
|
8
8
|
import * as keepalive from './keepalive/index.js';
|
|
9
|
+
import { pidAlive } from './lib/process.js';
|
|
10
|
+
import { readRuntime } from './runtime.js';
|
|
9
11
|
import { restartServices, startServices, statusServices, stopServices } from './service.js';
|
|
10
12
|
function managerInUse(config) {
|
|
11
13
|
const status = keepalive.status(config);
|
|
@@ -47,6 +49,18 @@ function restartAll(config = loadConfig()) {
|
|
|
47
49
|
}
|
|
48
50
|
return { ...restartServices(), managed: false };
|
|
49
51
|
}
|
|
52
|
+
/** Release Windows service binaries while npm replaces their package files. */
|
|
53
|
+
async function whileServicesStopped(config, task, { platform = process.platform, isRunning = () => Boolean(managerInUse(config)?.active || pidAlive(readRuntime().hostPid)), stop = stopAll, start = startAll, } = {}) {
|
|
54
|
+
if (platform !== 'win32' || !isRunning())
|
|
55
|
+
return task();
|
|
56
|
+
await stop(config);
|
|
57
|
+
try {
|
|
58
|
+
return await task();
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await start(config);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
50
64
|
/** Service status plus the keep-alive picture, which is what the TUI shows. */
|
|
51
65
|
async function fullStatus(config = loadConfig()) {
|
|
52
66
|
const [services, keepaliveStatus] = [await statusServices(), keepalive.status(config)];
|
|
@@ -56,4 +70,4 @@ async function fullStatus(config = loadConfig()) {
|
|
|
56
70
|
logsHint: keepalive.logsHint(config),
|
|
57
71
|
};
|
|
58
72
|
}
|
|
59
|
-
export { startAll, stopAll, restartAll, fullStatus, managerInUse };
|
|
73
|
+
export { startAll, stopAll, restartAll, fullStatus, managerInUse, whileServicesStopped };
|
package/dist/net-interfaces.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
// Interface names that are almost always virtual bridges rather than something
|
|
3
3
|
// a phone can reach. They are still listed, just ranked last.
|
|
4
|
-
const VIRTUAL_NAME_PATTERN = /^(docker|br-|virbr|veth|vmnet|vboxnet|lxcbr|cni|flannel|kube)/i;
|
|
4
|
+
const VIRTUAL_NAME_PATTERN = /^(docker|br-|virbr|veth|vmnet|vboxnet|lxcbr|cni|flannel|kube|vmware network adapter|virtualbox host-only)/i;
|
|
5
5
|
const TAILSCALE_NAME_PATTERN = /^(tailscale|ts)\d*$/i;
|
|
6
6
|
/**
|
|
7
7
|
* Is this address inside Tailscale's 100.64.0.0/10 CGNAT range? Interface names
|
|
@@ -18,12 +18,22 @@ function isTailscaleAddress(address) {
|
|
|
18
18
|
return false;
|
|
19
19
|
return first === 100 && second >= 64 && second <= 127;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Windows assigns 169.254 addresses when DHCP fails, and other devices cannot
|
|
23
|
+
* reach them.
|
|
24
|
+
*/
|
|
25
|
+
function isLinkLocalAddress(address) {
|
|
26
|
+
const octets = String(address).split('.');
|
|
27
|
+
if (octets.length !== 4)
|
|
28
|
+
return false;
|
|
29
|
+
return Number(octets[0]) === 169 && Number(octets[1]) === 254;
|
|
30
|
+
}
|
|
21
31
|
function classify(name, info) {
|
|
22
32
|
if (info.internal)
|
|
23
33
|
return 'loopback';
|
|
24
34
|
if (TAILSCALE_NAME_PATTERN.test(name) || isTailscaleAddress(info.address))
|
|
25
35
|
return 'tailscale';
|
|
26
|
-
if (VIRTUAL_NAME_PATTERN.test(name))
|
|
36
|
+
if (isLinkLocalAddress(info.address) || VIRTUAL_NAME_PATTERN.test(name))
|
|
27
37
|
return 'virtual';
|
|
28
38
|
return 'lan';
|
|
29
39
|
}
|
|
@@ -35,8 +45,7 @@ const KIND_ORDER = { tailscale: 0, lan: 1, virtual: 2, loopback: 3 };
|
|
|
35
45
|
* from anywhere, which is exactly the "works away from home without running a
|
|
36
46
|
* relay" case; plain LAN addresses follow, then virtual bridges, then loopback.
|
|
37
47
|
*/
|
|
38
|
-
function listReachableAddresses({ includeLoopback = true, includeIpv6 = false, } = {}) {
|
|
39
|
-
const interfaces = os.networkInterfaces();
|
|
48
|
+
function listReachableAddresses({ includeLoopback = true, includeIpv6 = false, interfaces = os.networkInterfaces(), } = {}) {
|
|
40
49
|
const results = [];
|
|
41
50
|
for (const [name, entries] of Object.entries(interfaces)) {
|
|
42
51
|
for (const info of entries || []) {
|
|
@@ -66,8 +75,8 @@ function listReachableAddresses({ includeLoopback = true, includeIpv6 = false, }
|
|
|
66
75
|
return results;
|
|
67
76
|
}
|
|
68
77
|
/** Best guess for the address to advertise when binding to every interface. */
|
|
69
|
-
function preferredLanAddress() {
|
|
70
|
-
const candidates = listReachableAddresses({ includeLoopback: false });
|
|
78
|
+
function preferredLanAddress(interfaces = os.networkInterfaces()) {
|
|
79
|
+
const candidates = listReachableAddresses({ includeLoopback: false, interfaces });
|
|
71
80
|
return candidates.length > 0 ? candidates[0].address : null;
|
|
72
81
|
}
|
|
73
82
|
export { isTailscaleAddress, listReachableAddresses, preferredLanAddress };
|
package/dist/paths.js
CHANGED
|
@@ -37,12 +37,24 @@ const PACKAGE_ROOT = findPackageRoot(import.meta.dirname);
|
|
|
37
37
|
* it launches the plugin itself, so honouring it would give "herdr-remote" run
|
|
38
38
|
* from a shell and the same tool run from a Herdr pane two different config
|
|
39
39
|
* files. One path, one config; `migrateLegacyConfig()` imports the old one.
|
|
40
|
+
* On Windows, configuration roams under `APPDATA` while state and logs stay
|
|
41
|
+
* local under `LOCALAPPDATA`, following Windows and Herdr's directory layout.
|
|
40
42
|
*/
|
|
41
|
-
function configDir() {
|
|
42
|
-
|
|
43
|
+
function configDir(env = process.env, platform = process.platform) {
|
|
44
|
+
if (env.HERDR_REMOTE_CONFIG_DIR)
|
|
45
|
+
return env.HERDR_REMOTE_CONFIG_DIR;
|
|
46
|
+
if (platform === 'win32') {
|
|
47
|
+
return path.join(env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'herdr-remote');
|
|
48
|
+
}
|
|
49
|
+
return path.join(os.homedir(), '.config', 'herdr-remote');
|
|
43
50
|
}
|
|
44
|
-
function stateDir() {
|
|
45
|
-
|
|
51
|
+
function stateDir(env = process.env, platform = process.platform) {
|
|
52
|
+
if (env.HERDR_REMOTE_STATE_DIR)
|
|
53
|
+
return env.HERDR_REMOTE_STATE_DIR;
|
|
54
|
+
if (platform === 'win32') {
|
|
55
|
+
return path.join(env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'herdr-remote');
|
|
56
|
+
}
|
|
57
|
+
return path.join(os.homedir(), '.local', 'state', 'herdr-remote');
|
|
46
58
|
}
|
|
47
59
|
function configPath() {
|
|
48
60
|
return path.join(configDir(), 'config.json');
|
package/dist/pty-session.js
CHANGED
|
@@ -9,9 +9,11 @@ class PtySession {
|
|
|
9
9
|
args;
|
|
10
10
|
cwd;
|
|
11
11
|
socketPath;
|
|
12
|
+
platform;
|
|
13
|
+
spawnPty;
|
|
12
14
|
terminal;
|
|
13
15
|
startedAt;
|
|
14
|
-
constructor({ command, args = [], cwd = os.homedir(), socketPath } = {}) {
|
|
16
|
+
constructor({ command, args = [], cwd = os.homedir(), socketPath, platform = process.platform, spawn: spawnPty = pty.spawn, } = {}) {
|
|
15
17
|
if (typeof command !== 'string' || command.length === 0)
|
|
16
18
|
throw new TypeError('command must be a non-empty string');
|
|
17
19
|
if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string'))
|
|
@@ -20,6 +22,8 @@ class PtySession {
|
|
|
20
22
|
this.args = args;
|
|
21
23
|
this.cwd = cwd;
|
|
22
24
|
this.socketPath = socketPath;
|
|
25
|
+
this.platform = platform;
|
|
26
|
+
this.spawnPty = spawnPty;
|
|
23
27
|
this.terminal = null;
|
|
24
28
|
this.startedAt = null;
|
|
25
29
|
}
|
|
@@ -44,12 +48,16 @@ class PtySession {
|
|
|
44
48
|
start({ cols, rows, onData, onExit }) {
|
|
45
49
|
if (this.terminal)
|
|
46
50
|
throw new Error('PTY session already started');
|
|
47
|
-
|
|
51
|
+
// The bundled ConPTY preserves Kitty sequences older system ConPTY drops,
|
|
52
|
+
// which is why Herdr embeds it too. System ConPTY's kill() forks a
|
|
53
|
+
// console-list helper without windowsHide, which flashes a window here.
|
|
54
|
+
this.terminal = this.spawnPty(this.command, this.args, {
|
|
48
55
|
name: 'xterm-256color',
|
|
49
56
|
cols: PtySession.clampDimension(cols, PtySession.DEFAULT_COLS),
|
|
50
57
|
rows: PtySession.clampDimension(rows, PtySession.DEFAULT_ROWS),
|
|
51
58
|
cwd: this.cwd,
|
|
52
59
|
env: PtySession.childEnv(this.socketPath),
|
|
60
|
+
...(this.platform === 'win32' ? { useConptyDll: true } : {}),
|
|
53
61
|
});
|
|
54
62
|
this.startedAt = new Date().toISOString();
|
|
55
63
|
const terminal = this.terminal;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Updating the relay a local or LAN workstation runs, on its own.
|
|
2
|
+
//
|
|
3
|
+
// The relay is a package of its own that herdr-remote installs. A release of
|
|
4
|
+
// the relay alone leaves herdr-remote's version where it was, so the update
|
|
5
|
+
// check for herdr-remote says "up to date" while the relay on disk falls
|
|
6
|
+
// behind. This checks the relay by itself, and installs the newest relay that
|
|
7
|
+
// this herdr-remote's dependency range allows.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { PACKAGE_ROOT } from './paths.js';
|
|
12
|
+
import { checkForUpdate, compareVersions, currentVersion, performUpdate, } from './updater.js';
|
|
13
|
+
const RELAY_PACKAGE = 'herdr-remote-relay';
|
|
14
|
+
/** The relay installed under this herdr-remote, read from disk: an update moves it. */
|
|
15
|
+
export function installedRelayVersion() {
|
|
16
|
+
try {
|
|
17
|
+
const manifest = createRequire(import.meta.url).resolve(`${RELAY_PACKAGE}/package.json`);
|
|
18
|
+
return JSON.parse(fs.readFileSync(manifest, 'utf8')).version;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** The relay versions this herdr-remote declares it works with, e.g. `^0.3.9`. */
|
|
25
|
+
function relayRange() {
|
|
26
|
+
try {
|
|
27
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'));
|
|
28
|
+
return manifest.dependencies?.[RELAY_PACKAGE] ?? null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether `version` is inside a caret range, the only kind herdr-remote
|
|
36
|
+
* declares. Below 1.0 a caret holds the minor version (`^0.3.9` is `0.3.x`
|
|
37
|
+
* from `0.3.9`), which is exactly the line a relay must not cross alone.
|
|
38
|
+
*/
|
|
39
|
+
export function withinCaretRange(version, range) {
|
|
40
|
+
const match = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(range.trim());
|
|
41
|
+
if (!match)
|
|
42
|
+
return false;
|
|
43
|
+
const floor = `${match[1]}.${match[2]}.${match[3]}`;
|
|
44
|
+
const [major, minor] = version.split('.').map((part) => Number.parseInt(part, 10));
|
|
45
|
+
const [floorMajor, floorMinor] = [Number(match[1]), Number(match[2])];
|
|
46
|
+
if (compareVersions(version, floor) < 0 || major !== floorMajor)
|
|
47
|
+
return false;
|
|
48
|
+
return floorMajor > 0 || minor === floorMinor;
|
|
49
|
+
}
|
|
50
|
+
/** Whether a newer relay than the installed one is out, and whether this herdr-remote can take it. */
|
|
51
|
+
export async function checkForRelayUpdate(options = {}) {
|
|
52
|
+
const installed = installedRelayVersion();
|
|
53
|
+
const result = await checkForUpdate({
|
|
54
|
+
...options,
|
|
55
|
+
packageName: RELAY_PACKAGE,
|
|
56
|
+
current: installed ?? '0.0.0',
|
|
57
|
+
});
|
|
58
|
+
if (!result.ok || !result.updateAvailable || !result.latest)
|
|
59
|
+
return result;
|
|
60
|
+
const range = relayRange();
|
|
61
|
+
if (range && !withinCaretRange(result.latest, range)) {
|
|
62
|
+
return { ...result, updateAvailable: false, needsNewerCli: true };
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Install the newest relay this herdr-remote allows: the same herdr-remote,
|
|
68
|
+
* reinstalled, which npm resolves afresh, relay included. The installed
|
|
69
|
+
* herdr-remote does not change, and neither does anything it was tested with
|
|
70
|
+
* but the relay. Success is the relay on disk, not npm's exit code.
|
|
71
|
+
*/
|
|
72
|
+
export async function performRelayUpdate({ relayVersion, readRelayVersion = installedRelayVersion, ...options }) {
|
|
73
|
+
const result = await performUpdate({ ...options, version: currentVersion() });
|
|
74
|
+
if (!result.ok)
|
|
75
|
+
return result;
|
|
76
|
+
const installed = readRelayVersion();
|
|
77
|
+
if (!installed || compareVersions(installed, relayVersion) < 0) {
|
|
78
|
+
return { ok: false, errorKey: 'relayUpdate.errorNotApplied', installed, output: result.output };
|
|
79
|
+
}
|
|
80
|
+
return { ...result, installed };
|
|
81
|
+
}
|
package/dist/service.js
CHANGED
|
@@ -99,6 +99,8 @@ function spawnDetached(spec) {
|
|
|
99
99
|
cwd: PACKAGE_ROOT,
|
|
100
100
|
env: { ...baseEnvironment(), ...spec.env },
|
|
101
101
|
detached: true,
|
|
102
|
+
// A detached console program can open a visible window from a console-less Windows parent.
|
|
103
|
+
windowsHide: true,
|
|
102
104
|
stdio: ['ignore', logFd, logFd],
|
|
103
105
|
});
|
|
104
106
|
child.unref();
|