herdr-remote 0.2.18 → 0.2.20
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/bin/herdr-remote.js +3 -225
- package/dist/agent-status.js +130 -0
- package/dist/binary-plist.js +157 -0
- package/dist/cli.js +255 -0
- package/dist/config.js +270 -0
- package/dist/connector/agent-watch.js +110 -0
- package/dist/connector/font-server.js +165 -0
- package/dist/connector/host-connector.js +430 -0
- package/dist/connector/lock.js +66 -0
- package/dist/connector/main.js +18 -0
- package/dist/connector/sessions.js +366 -0
- package/dist/connector/update-report.js +67 -0
- package/dist/connector/wire.js +42 -0
- package/{src → dist}/exit-codes.js +1 -4
- package/dist/font-subset.js +110 -0
- package/dist/herdr-api.js +246 -0
- package/dist/herdr-command.js +223 -0
- package/dist/herdr-plugin.js +116 -0
- package/dist/herdr-server.js +189 -0
- package/dist/i18n/en.js +223 -0
- package/dist/i18n/index.js +48 -0
- package/dist/i18n/zh.js +222 -0
- package/dist/keepalive/detached.js +78 -0
- package/dist/keepalive/environment.js +44 -0
- package/dist/keepalive/index.js +110 -0
- package/dist/keepalive/launchd.js +117 -0
- package/dist/keepalive/systemd.js +108 -0
- package/dist/keepalive/types.js +1 -0
- package/dist/lib/process.js +12 -0
- package/dist/lifecycle.js +59 -0
- package/dist/net-interfaces.js +73 -0
- package/dist/pasted-files.js +115 -0
- package/dist/paths.js +62 -0
- package/dist/pty-session.js +96 -0
- package/dist/relay-client.js +106 -0
- package/dist/relay-urls.js +99 -0
- package/dist/runtime.js +106 -0
- package/dist/service.js +260 -0
- package/dist/settings-model.js +298 -0
- package/dist/socket-discovery.js +48 -0
- package/dist/supervisor.js +319 -0
- package/dist/terminal-font/deps.js +73 -0
- package/dist/terminal-font/files.js +354 -0
- package/dist/terminal-font/formats.js +335 -0
- package/dist/terminal-font/index.js +187 -0
- package/dist/terminal-font/readers.js +316 -0
- package/dist/terminal-font/terminals.js +128 -0
- package/dist/terminal-palette.js +339 -0
- package/dist/tui/App.js +195 -0
- package/dist/tui/api.js +79 -0
- package/dist/tui/components/TextField.js +50 -0
- package/dist/tui/components/common.js +80 -0
- package/dist/tui/index.js +36 -0
- package/dist/tui/mouse/index.js +164 -0
- package/dist/tui/mouse/protocol.js +96 -0
- package/dist/tui/mouse/source.js +74 -0
- package/dist/tui/screens/About.js +166 -0
- package/dist/tui/screens/Herdr.js +74 -0
- package/dist/tui/screens/Keepalive.js +84 -0
- package/dist/tui/screens/Overview.js +36 -0
- package/dist/tui/screens/Pair.js +74 -0
- package/dist/tui/screens/Relay.js +242 -0
- package/dist/tui/screens/Services.js +63 -0
- package/dist/tui/screens/Wizard.js +235 -0
- package/dist/tui/terminal.js +39 -0
- package/dist/tui/theme.js +33 -0
- package/dist/updater.js +382 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +8 -8
- package/dist/tui.mjs +0 -5775
- package/src/agent-status.js +0 -139
- package/src/binary-plist.js +0 -146
- package/src/config.js +0 -454
- package/src/font-subset.js +0 -110
- package/src/herdr-api.js +0 -244
- package/src/herdr-command.js +0 -231
- package/src/herdr-plugin.js +0 -130
- package/src/herdr-server.js +0 -201
- package/src/host-connector.js +0 -1069
- package/src/i18n/en.js +0 -240
- package/src/i18n/index.js +0 -56
- package/src/i18n/zh.js +0 -239
- package/src/keepalive.js +0 -460
- package/src/lifecycle.js +0 -62
- package/src/net-interfaces.js +0 -76
- package/src/pasted-files.js +0 -176
- package/src/pty-session.js +0 -92
- package/src/service.js +0 -530
- package/src/settings-model.js +0 -323
- package/src/socket-discovery.js +0 -60
- package/src/state.js +0 -47
- package/src/supervisor.js +0 -250
- package/src/terminal-font.js +0 -1204
- package/src/terminal-palette.js +0 -325
- package/src/updater.js +0 -394
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// systemd --user: a unit that runs `herdr-remote run` and restarts it.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { ensureDir } from 'herdr-remote-relay/state';
|
|
7
|
+
import { cliEntryPoint, serviceEnvironment } from './environment.js';
|
|
8
|
+
const SYSTEMD_UNIT_NAME = 'herdr-remote.service';
|
|
9
|
+
function systemdUnitPath() {
|
|
10
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
11
|
+
return path.join(configHome, 'systemd', 'user', SYSTEMD_UNIT_NAME);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* One `KEY=value` per directive. An unquoted systemd value ends at the first
|
|
15
|
+
* space, and `%` starts a specifier, so a `PATH` with either in it would be
|
|
16
|
+
* silently truncated or rewritten.
|
|
17
|
+
*/
|
|
18
|
+
export function systemdEnvironmentLine(key, value) {
|
|
19
|
+
const text = String(value)
|
|
20
|
+
.replace(/[\r\n]+/g, ' ')
|
|
21
|
+
.replace(/%/g, '%%');
|
|
22
|
+
if (/^[\w@+=:,./-]*$/.test(text))
|
|
23
|
+
return `Environment=${key}=${text}`;
|
|
24
|
+
return `Environment="${key}=${text.replace(/([\\"])/g, '\\$1')}"`;
|
|
25
|
+
}
|
|
26
|
+
export function renderSystemdUnit({ nodePath = process.execPath, entryPoint = cliEntryPoint(), environment = {}, } = {}) {
|
|
27
|
+
const environmentLines = Object.entries(environment)
|
|
28
|
+
.map(([key, value]) => systemdEnvironmentLine(key, value))
|
|
29
|
+
.join('\n');
|
|
30
|
+
return `[Unit]
|
|
31
|
+
Description=Herdr Remote (relay and host connector)
|
|
32
|
+
Documentation=https://github.com/dibin666/herdr-remote
|
|
33
|
+
After=default.target
|
|
34
|
+
|
|
35
|
+
[Service]
|
|
36
|
+
Type=simple
|
|
37
|
+
ExecStart=${nodePath} ${entryPoint} run
|
|
38
|
+
Restart=always
|
|
39
|
+
RestartSec=3
|
|
40
|
+
# Give the relay and host connector time to close sessions cleanly.
|
|
41
|
+
TimeoutStopSec=15
|
|
42
|
+
${environmentLines}
|
|
43
|
+
|
|
44
|
+
[Install]
|
|
45
|
+
WantedBy=default.target
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
48
|
+
function systemctl(args) {
|
|
49
|
+
return spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8', stdio: 'pipe' });
|
|
50
|
+
}
|
|
51
|
+
/** Run a systemctl command that must succeed, throwing its stderr when it does not. */
|
|
52
|
+
function systemctlOrThrow(args, failure) {
|
|
53
|
+
const result = systemctl(args);
|
|
54
|
+
if (result.status !== 0) {
|
|
55
|
+
throw new Error(`${failure}: ${String(result.stderr || '').trim()}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export const systemd = {
|
|
59
|
+
name: 'systemd',
|
|
60
|
+
status() {
|
|
61
|
+
const installed = fs.existsSync(systemdUnitPath());
|
|
62
|
+
if (!installed)
|
|
63
|
+
return { manager: 'systemd', installed: false, active: false, enabled: false };
|
|
64
|
+
const active = systemctl(['is-active', SYSTEMD_UNIT_NAME]);
|
|
65
|
+
const enabled = systemctl(['is-enabled', SYSTEMD_UNIT_NAME]);
|
|
66
|
+
const lingering = spawnSync('loginctl', ['show-user', os.userInfo().username, '--property=Linger'], { encoding: 'utf8' });
|
|
67
|
+
return {
|
|
68
|
+
manager: 'systemd',
|
|
69
|
+
installed: true,
|
|
70
|
+
active: String(active.stdout || '').trim() === 'active',
|
|
71
|
+
enabled: String(enabled.stdout || '')
|
|
72
|
+
.trim()
|
|
73
|
+
.startsWith('enabled'),
|
|
74
|
+
linger: String(lingering.stdout || '').includes('Linger=yes'),
|
|
75
|
+
unitPath: systemdUnitPath(),
|
|
76
|
+
state: String(active.stdout || active.stderr || '').trim(),
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
install() {
|
|
80
|
+
const unitPath = systemdUnitPath();
|
|
81
|
+
ensureDir(path.dirname(unitPath));
|
|
82
|
+
fs.writeFileSync(unitPath, renderSystemdUnit({ environment: serviceEnvironment() }), {
|
|
83
|
+
mode: 0o644,
|
|
84
|
+
});
|
|
85
|
+
systemctlOrThrow(['daemon-reload'], 'systemctl --user daemon-reload failed');
|
|
86
|
+
systemctlOrThrow(['enable', '--now', SYSTEMD_UNIT_NAME], 'systemctl --user enable --now failed');
|
|
87
|
+
return { ok: true, unitPath, hint: `loginctl enable-linger ${os.userInfo().username}` };
|
|
88
|
+
},
|
|
89
|
+
uninstall() {
|
|
90
|
+
const unitPath = systemdUnitPath();
|
|
91
|
+
systemctl(['disable', '--now', SYSTEMD_UNIT_NAME]);
|
|
92
|
+
fs.rmSync(unitPath, { force: true });
|
|
93
|
+
systemctl(['daemon-reload']);
|
|
94
|
+
return { ok: true, unitPath };
|
|
95
|
+
},
|
|
96
|
+
restart() {
|
|
97
|
+
const result = systemctl(['restart', SYSTEMD_UNIT_NAME]);
|
|
98
|
+
if (result.status !== 0)
|
|
99
|
+
throw new Error(String(result.stderr || '').trim() || 'systemctl restart failed');
|
|
100
|
+
return { ok: true };
|
|
101
|
+
},
|
|
102
|
+
stop() {
|
|
103
|
+
systemctl(['stop', SYSTEMD_UNIT_NAME]);
|
|
104
|
+
},
|
|
105
|
+
logsHint() {
|
|
106
|
+
return `journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Whether `pid` names a running process, including one owned by another user. */
|
|
2
|
+
export function pidAlive(pid) {
|
|
3
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
4
|
+
return false;
|
|
5
|
+
try {
|
|
6
|
+
process.kill(pid, 0);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
return error.code === 'EPERM';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Start/stop/restart that respect the keep-alive manager.
|
|
2
|
+
//
|
|
3
|
+
// When systemd or launchd supervises the services, killing the processes just
|
|
4
|
+
// makes the manager start them again — and starting them by hand produces a
|
|
5
|
+
// second, unmanaged copy fighting for the port. Every entry point goes through
|
|
6
|
+
// here so the managed and unmanaged paths behave the same from the outside.
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import * as keepalive from './keepalive/index.js';
|
|
9
|
+
import { restartServices, startServices, statusServices, stopServices } from './service.js';
|
|
10
|
+
function managerInUse(config) {
|
|
11
|
+
const status = keepalive.status(config);
|
|
12
|
+
return status.installed || status.active ? status : null;
|
|
13
|
+
}
|
|
14
|
+
function startAll(config = loadConfig()) {
|
|
15
|
+
const managed = managerInUse(config);
|
|
16
|
+
if (managed) {
|
|
17
|
+
// Start means "make it run", not "restart it". Herdr runs this through the
|
|
18
|
+
// plugin's startup hook every time a Herdr server starts, and a restart
|
|
19
|
+
// there dropped every browser attached to the workstation — including the
|
|
20
|
+
// one that had just asked for that Herdr to be started.
|
|
21
|
+
if (!managed.active)
|
|
22
|
+
keepalive.restart(config);
|
|
23
|
+
return {
|
|
24
|
+
ok: true,
|
|
25
|
+
managed: true,
|
|
26
|
+
manager: managed.manager,
|
|
27
|
+
alreadyRunning: Boolean(managed.active),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { ...startServices(), managed: false };
|
|
31
|
+
}
|
|
32
|
+
function stopAll(config = loadConfig()) {
|
|
33
|
+
const result = keepalive.stopManaged(config);
|
|
34
|
+
if (result.managed) {
|
|
35
|
+
// The manager stops the supervisor, which stops its children; clear the
|
|
36
|
+
// recorded pids so status does not report ghosts.
|
|
37
|
+
stopServices();
|
|
38
|
+
return { ok: true, managed: true, manager: result.manager };
|
|
39
|
+
}
|
|
40
|
+
return { ...stopServices(), managed: false };
|
|
41
|
+
}
|
|
42
|
+
function restartAll(config = loadConfig()) {
|
|
43
|
+
const managed = managerInUse(config);
|
|
44
|
+
if (managed) {
|
|
45
|
+
keepalive.restart(config);
|
|
46
|
+
return { ok: true, managed: true, manager: managed.manager };
|
|
47
|
+
}
|
|
48
|
+
return { ...restartServices(), managed: false };
|
|
49
|
+
}
|
|
50
|
+
/** Service status plus the keep-alive picture, which is what the TUI shows. */
|
|
51
|
+
async function fullStatus(config = loadConfig()) {
|
|
52
|
+
const [services, keepaliveStatus] = [await statusServices(), keepalive.status(config)];
|
|
53
|
+
return {
|
|
54
|
+
...services,
|
|
55
|
+
keepalive: keepaliveStatus,
|
|
56
|
+
logsHint: keepalive.logsHint(config),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export { startAll, stopAll, restartAll, fullStatus, managerInUse };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
// Interface names that are almost always virtual bridges rather than something
|
|
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;
|
|
5
|
+
const TAILSCALE_NAME_PATTERN = /^(tailscale|ts)\d*$/i;
|
|
6
|
+
/**
|
|
7
|
+
* Is this address inside Tailscale's 100.64.0.0/10 CGNAT range? Interface names
|
|
8
|
+
* differ per platform (tailscale0 on Linux, utunN on macOS), so the address
|
|
9
|
+
* range is the portable signal.
|
|
10
|
+
*/
|
|
11
|
+
function isTailscaleAddress(address) {
|
|
12
|
+
const octets = String(address).split('.');
|
|
13
|
+
if (octets.length !== 4)
|
|
14
|
+
return false;
|
|
15
|
+
const first = Number(octets[0]);
|
|
16
|
+
const second = Number(octets[1]);
|
|
17
|
+
if (!Number.isInteger(first) || !Number.isInteger(second))
|
|
18
|
+
return false;
|
|
19
|
+
return first === 100 && second >= 64 && second <= 127;
|
|
20
|
+
}
|
|
21
|
+
function classify(name, info) {
|
|
22
|
+
if (info.internal)
|
|
23
|
+
return 'loopback';
|
|
24
|
+
if (TAILSCALE_NAME_PATTERN.test(name) || isTailscaleAddress(info.address))
|
|
25
|
+
return 'tailscale';
|
|
26
|
+
if (VIRTUAL_NAME_PATTERN.test(name))
|
|
27
|
+
return 'virtual';
|
|
28
|
+
return 'lan';
|
|
29
|
+
}
|
|
30
|
+
const KIND_ORDER = { tailscale: 0, lan: 1, virtual: 2, loopback: 3 };
|
|
31
|
+
/**
|
|
32
|
+
* Addresses this machine can be reached at, best candidate first.
|
|
33
|
+
*
|
|
34
|
+
* Tailscale addresses come first because a device on the tailnet reaches them
|
|
35
|
+
* from anywhere, which is exactly the "works away from home without running a
|
|
36
|
+
* relay" case; plain LAN addresses follow, then virtual bridges, then loopback.
|
|
37
|
+
*/
|
|
38
|
+
function listReachableAddresses({ includeLoopback = true, includeIpv6 = false, } = {}) {
|
|
39
|
+
const interfaces = os.networkInterfaces();
|
|
40
|
+
const results = [];
|
|
41
|
+
for (const [name, entries] of Object.entries(interfaces)) {
|
|
42
|
+
for (const info of entries || []) {
|
|
43
|
+
// Older Node releases reported the family as a number.
|
|
44
|
+
const rawFamily = info.family;
|
|
45
|
+
const family = typeof rawFamily === 'string' ? rawFamily : `IPv${rawFamily}`;
|
|
46
|
+
if (family !== 'IPv4' && !(includeIpv6 && family === 'IPv6'))
|
|
47
|
+
continue;
|
|
48
|
+
const kind = classify(name, info);
|
|
49
|
+
if (kind === 'loopback' && !includeLoopback)
|
|
50
|
+
continue;
|
|
51
|
+
results.push({
|
|
52
|
+
name,
|
|
53
|
+
address: info.address,
|
|
54
|
+
family,
|
|
55
|
+
kind,
|
|
56
|
+
internal: Boolean(info.internal),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
results.sort((a, b) => {
|
|
61
|
+
const byKind = KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
|
|
62
|
+
if (byKind !== 0)
|
|
63
|
+
return byKind;
|
|
64
|
+
return a.address.localeCompare(b.address);
|
|
65
|
+
});
|
|
66
|
+
return results;
|
|
67
|
+
}
|
|
68
|
+
/** Best guess for the address to advertise when binding to every interface. */
|
|
69
|
+
function preferredLanAddress() {
|
|
70
|
+
const candidates = listReachableAddresses({ includeLoopback: false });
|
|
71
|
+
return candidates.length > 0 ? candidates[0].address : null;
|
|
72
|
+
}
|
|
73
|
+
export { isTailscaleAddress, listReachableAddresses, preferredLanAddress };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure file storage and validation for pasted clipboard images.
|
|
3
|
+
*
|
|
4
|
+
* Security requirements:
|
|
5
|
+
* 1. Pinned directory: writes strictly to path.join(stateDir(), 'pasted') (directory mode 0o700).
|
|
6
|
+
* 2. Unpredictable filename: generated via crypto.randomUUID(), never using client-provided strings.
|
|
7
|
+
* 3. Whitelisted extension: derived exclusively from verified MIME type.
|
|
8
|
+
* 4. Host-side size check: re-validated to enforce <= 3 MB independently of relay checks.
|
|
9
|
+
* 5. Magic bytes sniffing: inspects raw binary headers (PNG, JPEG, GIF, WebP) to prevent file-type spoofing.
|
|
10
|
+
* 6. File mode: files are created with mode 0o600.
|
|
11
|
+
* 7. Automatic pruning: caps at 20 files and 50 MB (oldest deleted first), removes >24h stale files at startup.
|
|
12
|
+
*/
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { stateDir } from './paths.js';
|
|
17
|
+
import { ensureDir } from 'herdr-remote-relay/state';
|
|
18
|
+
import { PASTE_MAX_BYTES, PASTE_IMAGE_EXTENSIONS, isPasteImageMime, hasImageSignature, } from 'herdr-remote-relay/protocol';
|
|
19
|
+
const MAX_SAVED_FILES = 20;
|
|
20
|
+
const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
21
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
22
|
+
function getPastedDir() {
|
|
23
|
+
const dir = path.join(stateDir(), 'pasted');
|
|
24
|
+
ensureDir(dir);
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Prunes the pasted directory:
|
|
29
|
+
* - Removes files older than maxAgeMs (default 24 hours).
|
|
30
|
+
* - Caps total file count (default 20) and total disk footprint (default 50 MB),
|
|
31
|
+
* deleting oldest files first.
|
|
32
|
+
*/
|
|
33
|
+
function cleanPastedDir({ dir = getPastedDir(), maxFiles = MAX_SAVED_FILES, maxBytes = MAX_TOTAL_BYTES, maxAgeMs = MAX_AGE_MS, now = Date.now(), } = {}) {
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const fileInfos = [];
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (!entry.isFile())
|
|
44
|
+
continue;
|
|
45
|
+
const fullPath = path.join(dir, entry.name);
|
|
46
|
+
try {
|
|
47
|
+
const stat = fs.statSync(fullPath);
|
|
48
|
+
if (now - stat.mtimeMs > maxAgeMs) {
|
|
49
|
+
try {
|
|
50
|
+
fs.unlinkSync(fullPath);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Removed by another process meanwhile, or not ours to remove: skip it.
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
fileInfos.push({ path: fullPath, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// The file vanished between listing and stat; nothing to account for.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Sort oldest first
|
|
64
|
+
fileInfos.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
65
|
+
let totalSize = fileInfos.reduce((sum, f) => sum + f.size, 0);
|
|
66
|
+
while (fileInfos.length > maxFiles || totalSize > maxBytes) {
|
|
67
|
+
const oldest = fileInfos.shift();
|
|
68
|
+
if (!oldest)
|
|
69
|
+
break;
|
|
70
|
+
try {
|
|
71
|
+
fs.unlinkSync(oldest.path);
|
|
72
|
+
totalSize -= oldest.size;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Pruning is best effort; the next save prunes again.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Validates base64 data, checks payload limits, sniffs magic bytes against declared MIME,
|
|
81
|
+
* writes to state storage with mode 0o600, and returns the absolute local path.
|
|
82
|
+
*/
|
|
83
|
+
function savePastedFile({ mime, dataBase64, dir = getPastedDir(), }) {
|
|
84
|
+
if (!isPasteImageMime(mime)) {
|
|
85
|
+
throw new Error(`unsupported MIME type: ${mime}`);
|
|
86
|
+
}
|
|
87
|
+
if (typeof dataBase64 !== 'string') {
|
|
88
|
+
throw new Error('missing or invalid dataBase64 payload');
|
|
89
|
+
}
|
|
90
|
+
const buf = Buffer.from(dataBase64, 'base64');
|
|
91
|
+
if (buf.length > PASTE_MAX_BYTES) {
|
|
92
|
+
throw new Error(`file size (${buf.length} bytes) exceeds maximum limit of 3 MB`);
|
|
93
|
+
}
|
|
94
|
+
if (buf.length === 0) {
|
|
95
|
+
throw new Error('file payload is empty');
|
|
96
|
+
}
|
|
97
|
+
if (!hasImageSignature(mime, buf)) {
|
|
98
|
+
throw new Error(`file magic bytes do not match declared MIME type ${mime}`);
|
|
99
|
+
}
|
|
100
|
+
// Prune before writing new file
|
|
101
|
+
cleanPastedDir({ dir });
|
|
102
|
+
const fileName = `${crypto.randomUUID()}${PASTE_IMAGE_EXTENSIONS[mime]}`;
|
|
103
|
+
const filePath = path.join(dir, fileName);
|
|
104
|
+
fs.writeFileSync(filePath, buf, { mode: 0o600, flag: 'wx' });
|
|
105
|
+
try {
|
|
106
|
+
fs.chmodSync(filePath, 0o600);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// The file was created 0600 already; chmod only guards against a lax umask.
|
|
110
|
+
}
|
|
111
|
+
// Prune again after writing to strictly observe count/size ceiling
|
|
112
|
+
cleanPastedDir({ dir });
|
|
113
|
+
return filePath;
|
|
114
|
+
}
|
|
115
|
+
export { cleanPastedDir, savePastedFile };
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Where herdr-remote keeps its files: the package itself, the configuration
|
|
2
|
+
// directory and the state directory.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
/**
|
|
7
|
+
* Locate the package directory by walking up to our own package.json.
|
|
8
|
+
*
|
|
9
|
+
* This module runs from src/ under the tests and from dist/ once built.
|
|
10
|
+
* Everything that matters — the plugin manifest, the host connector entry
|
|
11
|
+
* point, the systemd unit's ExecStart — is resolved from this value, so it has
|
|
12
|
+
* to be independent of where the code happens to be loaded from.
|
|
13
|
+
*/
|
|
14
|
+
function findPackageRoot(start) {
|
|
15
|
+
let directory = start;
|
|
16
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
17
|
+
const candidate = path.join(directory, 'package.json');
|
|
18
|
+
try {
|
|
19
|
+
if (JSON.parse(fs.readFileSync(candidate, 'utf8')).name === 'herdr-remote')
|
|
20
|
+
return directory;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Keep climbing: a missing or unrelated package.json is expected.
|
|
24
|
+
}
|
|
25
|
+
const parent = path.dirname(directory);
|
|
26
|
+
if (parent === directory)
|
|
27
|
+
break;
|
|
28
|
+
directory = parent;
|
|
29
|
+
}
|
|
30
|
+
return path.resolve(start, '..');
|
|
31
|
+
}
|
|
32
|
+
const PACKAGE_ROOT = findPackageRoot(import.meta.dirname);
|
|
33
|
+
/**
|
|
34
|
+
* The canonical configuration directory.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately NOT $HERDR_PLUGIN_CONFIG_DIR: Herdr sets that variable only when
|
|
37
|
+
* it launches the plugin itself, so honouring it would give "herdr-remote" run
|
|
38
|
+
* from a shell and the same tool run from a Herdr pane two different config
|
|
39
|
+
* files. One path, one config; `migrateLegacyConfig()` imports the old one.
|
|
40
|
+
*/
|
|
41
|
+
function configDir() {
|
|
42
|
+
return process.env.HERDR_REMOTE_CONFIG_DIR || path.join(os.homedir(), '.config', 'herdr-remote');
|
|
43
|
+
}
|
|
44
|
+
function stateDir() {
|
|
45
|
+
return (process.env.HERDR_REMOTE_STATE_DIR || path.join(os.homedir(), '.local', 'state', 'herdr-remote'));
|
|
46
|
+
}
|
|
47
|
+
function configPath() {
|
|
48
|
+
return path.join(configDir(), 'config.json');
|
|
49
|
+
}
|
|
50
|
+
function runtimeStatePath() {
|
|
51
|
+
return path.join(stateDir(), 'runtime.json');
|
|
52
|
+
}
|
|
53
|
+
/** Where a service (relay, host, supervisor) writes its output. */
|
|
54
|
+
function logPath(name) {
|
|
55
|
+
return path.join(stateDir(), `${name}.log`);
|
|
56
|
+
}
|
|
57
|
+
function legacyConfigPath() {
|
|
58
|
+
return process.env.HERDR_PLUGIN_CONFIG_DIR
|
|
59
|
+
? path.join(process.env.HERDR_PLUGIN_CONFIG_DIR, 'config.json')
|
|
60
|
+
: null;
|
|
61
|
+
}
|
|
62
|
+
export { PACKAGE_ROOT, configDir, stateDir, configPath, runtimeStatePath, logPath, legacyConfigPath, };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import pty from 'node-pty';
|
|
3
|
+
class PtySession {
|
|
4
|
+
static DEFAULT_COLS = 100;
|
|
5
|
+
static DEFAULT_ROWS = 30;
|
|
6
|
+
static MIN_DIMENSION = 2;
|
|
7
|
+
static MAX_DIMENSION = 500;
|
|
8
|
+
command;
|
|
9
|
+
args;
|
|
10
|
+
cwd;
|
|
11
|
+
socketPath;
|
|
12
|
+
terminal;
|
|
13
|
+
startedAt;
|
|
14
|
+
constructor({ command, args = [], cwd = os.homedir(), socketPath } = {}) {
|
|
15
|
+
if (typeof command !== 'string' || command.length === 0)
|
|
16
|
+
throw new TypeError('command must be a non-empty string');
|
|
17
|
+
if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string'))
|
|
18
|
+
throw new TypeError('args must be strings');
|
|
19
|
+
this.command = command;
|
|
20
|
+
this.args = args;
|
|
21
|
+
this.cwd = cwd;
|
|
22
|
+
this.socketPath = socketPath;
|
|
23
|
+
this.terminal = null;
|
|
24
|
+
this.startedAt = null;
|
|
25
|
+
}
|
|
26
|
+
static childEnv(socketPath) {
|
|
27
|
+
const env = { ...process.env };
|
|
28
|
+
for (const key of Object.keys(env)) {
|
|
29
|
+
if (key.startsWith('HERDR_'))
|
|
30
|
+
delete env[key];
|
|
31
|
+
}
|
|
32
|
+
if (socketPath)
|
|
33
|
+
env.HERDR_SOCKET_PATH = socketPath;
|
|
34
|
+
env.TERM = 'xterm-256color';
|
|
35
|
+
env.COLORTERM = 'truecolor';
|
|
36
|
+
return env;
|
|
37
|
+
}
|
|
38
|
+
static clampDimension(value, fallback) {
|
|
39
|
+
const numeric = Number(value);
|
|
40
|
+
if (!Number.isInteger(numeric))
|
|
41
|
+
return fallback;
|
|
42
|
+
return Math.min(PtySession.MAX_DIMENSION, Math.max(PtySession.MIN_DIMENSION, numeric));
|
|
43
|
+
}
|
|
44
|
+
start({ cols, rows, onData, onExit }) {
|
|
45
|
+
if (this.terminal)
|
|
46
|
+
throw new Error('PTY session already started');
|
|
47
|
+
this.terminal = pty.spawn(this.command, this.args, {
|
|
48
|
+
name: 'xterm-256color',
|
|
49
|
+
cols: PtySession.clampDimension(cols, PtySession.DEFAULT_COLS),
|
|
50
|
+
rows: PtySession.clampDimension(rows, PtySession.DEFAULT_ROWS),
|
|
51
|
+
cwd: this.cwd,
|
|
52
|
+
env: PtySession.childEnv(this.socketPath),
|
|
53
|
+
});
|
|
54
|
+
this.startedAt = new Date().toISOString();
|
|
55
|
+
const terminal = this.terminal;
|
|
56
|
+
if (onData)
|
|
57
|
+
terminal.onData(onData);
|
|
58
|
+
if (onExit) {
|
|
59
|
+
terminal.onExit((event) => {
|
|
60
|
+
this.terminal = null;
|
|
61
|
+
onExit(event);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
write(data) {
|
|
67
|
+
if (!this.terminal)
|
|
68
|
+
return;
|
|
69
|
+
this.terminal.write(Buffer.isBuffer(data) ? data.toString('utf8') : String(data));
|
|
70
|
+
}
|
|
71
|
+
resize(cols, rows) {
|
|
72
|
+
if (!this.terminal)
|
|
73
|
+
return;
|
|
74
|
+
this.terminal.resize(PtySession.clampDimension(cols, PtySession.DEFAULT_COLS), PtySession.clampDimension(rows, PtySession.DEFAULT_ROWS));
|
|
75
|
+
}
|
|
76
|
+
info() {
|
|
77
|
+
return {
|
|
78
|
+
pid: this.terminal?.pid || null,
|
|
79
|
+
command: this.command,
|
|
80
|
+
cols: this.terminal?.cols || null,
|
|
81
|
+
rows: this.terminal?.rows || null,
|
|
82
|
+
cwd: this.cwd,
|
|
83
|
+
createdAt: this.startedAt,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
kill() {
|
|
87
|
+
try {
|
|
88
|
+
this.terminal?.kill();
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// The process has already exited.
|
|
92
|
+
}
|
|
93
|
+
this.terminal = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export { PtySession };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Talking to a relay's HTTP API from this workstation: health, status, pairing.
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import { resolveAdminOrigin } from './relay-urls.js';
|
|
5
|
+
import { ensureRuntime } from './runtime.js';
|
|
6
|
+
/**
|
|
7
|
+
* Minimal JSON client. Picks http or https from the URL: a self-hosted relay is
|
|
8
|
+
* reached over https, while the local relay is plain http on loopback.
|
|
9
|
+
*/
|
|
10
|
+
export function requestJson(urlString, options = {}) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(urlString);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
reject(error);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
21
|
+
const request = transport.request(url, {
|
|
22
|
+
method: options.method || 'GET',
|
|
23
|
+
headers: options.headers || {},
|
|
24
|
+
timeout: options.timeout || 1500,
|
|
25
|
+
}, (response) => {
|
|
26
|
+
const chunks = [];
|
|
27
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
28
|
+
response.on('end', () => {
|
|
29
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
30
|
+
let body;
|
|
31
|
+
try {
|
|
32
|
+
body = JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
body = { raw: text };
|
|
36
|
+
}
|
|
37
|
+
const status = response.statusCode || 0;
|
|
38
|
+
if (status >= 400) {
|
|
39
|
+
const error = new Error(typeof body.message === 'string' ? body.message : `HTTP ${status}`);
|
|
40
|
+
error.statusCode = status;
|
|
41
|
+
error.body = body;
|
|
42
|
+
reject(error);
|
|
43
|
+
}
|
|
44
|
+
else
|
|
45
|
+
resolve(body);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
request.on('timeout', () => request.destroy(new Error('request timed out')));
|
|
49
|
+
request.on('error', reject);
|
|
50
|
+
if (options.body)
|
|
51
|
+
request.write(options.body);
|
|
52
|
+
request.end();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export function healthUrl(config) {
|
|
56
|
+
return `${resolveAdminOrigin(config)}/healthz`;
|
|
57
|
+
}
|
|
58
|
+
/** This workstation's own credentials, which scope a relay's answers to it. */
|
|
59
|
+
export function hostHeaders() {
|
|
60
|
+
const state = ensureRuntime();
|
|
61
|
+
return { 'X-Herdr-Host-Id': state.hostId, 'X-Herdr-Host-Token': state.hostToken };
|
|
62
|
+
}
|
|
63
|
+
export async function waitForRelay(config, { attempts = 20, delayMs = 100 } = {}) {
|
|
64
|
+
let lastError;
|
|
65
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
66
|
+
try {
|
|
67
|
+
return await requestJson(healthUrl(config), { timeout: 800 });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
lastError = error;
|
|
71
|
+
}
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
73
|
+
}
|
|
74
|
+
throw lastError || new Error('relay did not become ready');
|
|
75
|
+
}
|
|
76
|
+
export async function waitForHost(config, { attempts = 30, delayMs = 100 } = {}) {
|
|
77
|
+
const headers = hostHeaders();
|
|
78
|
+
const hostId = headers['X-Herdr-Host-Id'];
|
|
79
|
+
const statusEndpoint = `${resolveAdminOrigin(config)}/api/status`;
|
|
80
|
+
let lastStatus = null;
|
|
81
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
82
|
+
try {
|
|
83
|
+
lastStatus = await requestJson(statusEndpoint, {
|
|
84
|
+
timeout: 800,
|
|
85
|
+
headers,
|
|
86
|
+
});
|
|
87
|
+
if (lastStatus.hosts?.some((host) => host.id === hostId))
|
|
88
|
+
return lastStatus;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
lastStatus = { ok: false, message: error.message };
|
|
92
|
+
}
|
|
93
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
94
|
+
}
|
|
95
|
+
const error = new Error(lastStatus?.message || 'the Herdr host connector did not register with the relay');
|
|
96
|
+
error.health = lastStatus;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
export function extractPairingCode(response) {
|
|
100
|
+
const answer = response;
|
|
101
|
+
const code = answer?.code || answer?.pairCode;
|
|
102
|
+
if (typeof code !== 'string' || code.length === 0) {
|
|
103
|
+
throw new Error('the relay response did not contain a valid pairing code');
|
|
104
|
+
}
|
|
105
|
+
return code;
|
|
106
|
+
}
|