pinggy 0.6.1 → 0.6.3
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/ARCHITECTURE.md +334 -10
- package/CLAUDE.md +20 -2
- package/README.md +90 -0
- package/dist/TunnelManager-NGSUBZYI.js +13 -0
- package/dist/{TunnelTui-GBRJDOQD.js → TunnelTui-UCG2YSLX.js} +6 -18
- package/dist/{chunk-I4HXBP5G.js → chunk-7EQKHVQX.js} +34 -2
- package/dist/{chunk-GBYF2H4H.js → chunk-AFMTIELL.js} +4 -0
- package/dist/{chunk-QVV3MRKY.js → chunk-GESMZQHI.js} +47 -119
- package/dist/{chunk-JQAQRJPE.js → chunk-MV56U3E6.js} +2 -2
- package/dist/{chunk-MT44NAXX.js → chunk-PD7HXVQ3.js} +1 -1
- package/dist/{chunk-LV3J6TAV.js → chunk-QNUVQ6KD.js} +199 -17
- package/dist/{chunk-7F67A6LY.js → chunk-TG6LBCPG.js} +3486 -236
- package/dist/chunk-XZ64ZAGO.js +18 -0
- package/dist/{chunk-DJ5WX37D.js → chunk-Y435RMHQ.js} +7 -3
- package/dist/{configStore-XRWOTTA4.js → configStore-RR5ZU4E3.js} +3 -3
- package/dist/{daemonChild-VX7SCGVS.js → daemonChild-E73NFWKE.js} +13 -8
- package/dist/{daemonConfig-G6S46GPJ.js → daemonConfig-QVXQ47XH.js} +2 -2
- package/dist/index.cjs +3919 -277
- package/dist/index.d.cts +135 -53
- package/dist/index.d.ts +135 -53
- package/dist/index.js +13 -10
- package/dist/ipcClient-XGF2KCQO.js +10 -0
- package/dist/{main-GXPUTY6N.js → main-M6MACH7D.js} +9 -8
- package/dist/workers/file_serve_worker.js +1 -1
- package/package.json +5 -2
- package/scripts/install/install.ps1 +261 -0
- package/scripts/install/install.sh +387 -0
- package/dist/TunnelManager-EHMPIQDZ.js +0 -11
- package/dist/ipcClient-URHJKNEH.js +0 -6
|
@@ -1,29 +1,33 @@
|
|
|
1
1
|
import {
|
|
2
|
-
ErrorCode,
|
|
3
2
|
TunnelClient,
|
|
3
|
+
TunnelConfigV1Schema,
|
|
4
4
|
TunnelOperations,
|
|
5
5
|
buildRemoteManagementWsUrl,
|
|
6
6
|
daemonIpcVersion,
|
|
7
|
+
ensureDaemonRunning,
|
|
7
8
|
getDaemonInfo,
|
|
8
9
|
initiateRemoteManagement,
|
|
9
10
|
ipcMismatchMessage,
|
|
10
11
|
isDaemonRunning,
|
|
11
|
-
isErrorResponse,
|
|
12
12
|
isIpcCompatible,
|
|
13
13
|
startDaemon,
|
|
14
14
|
startRemoteManagement,
|
|
15
15
|
stopDaemon
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-GESMZQHI.js";
|
|
17
17
|
import {
|
|
18
18
|
readDaemonConfig
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-PD7HXVQ3.js";
|
|
20
20
|
import {
|
|
21
21
|
DaemonHost,
|
|
22
|
+
DeviceAgentState,
|
|
23
|
+
HTTP_NOT_FOUND,
|
|
24
|
+
IPCClient,
|
|
25
|
+
IPCHttpError,
|
|
22
26
|
IPC_VERSION,
|
|
23
27
|
Route,
|
|
24
28
|
SessionMode,
|
|
25
29
|
ShutdownStatus
|
|
26
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-7EQKHVQX.js";
|
|
27
31
|
import {
|
|
28
32
|
ConfigVerb,
|
|
29
33
|
SUBCOMMANDS,
|
|
@@ -39,36 +43,46 @@ import {
|
|
|
39
43
|
saveConfig,
|
|
40
44
|
updateConfigAutoStart,
|
|
41
45
|
updateTunnelConfig,
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
upsertConfig,
|
|
47
|
+
validateName,
|
|
48
|
+
validateNameStrict
|
|
49
|
+
} from "./chunk-Y435RMHQ.js";
|
|
44
50
|
import {
|
|
51
|
+
createQrCodes
|
|
52
|
+
} from "./chunk-XZ64ZAGO.js";
|
|
53
|
+
import {
|
|
54
|
+
ErrorCode,
|
|
45
55
|
TunnelManager,
|
|
56
|
+
TunnelStateType,
|
|
46
57
|
detachAllTunnelLoggers,
|
|
47
58
|
errorMessage,
|
|
48
59
|
getLocalAddress,
|
|
49
60
|
getRandomId,
|
|
50
61
|
getVersion,
|
|
62
|
+
isErrorResponse,
|
|
51
63
|
isTunnelLoggingEnabled,
|
|
52
64
|
isValidPort,
|
|
65
|
+
newErrorResponse,
|
|
53
66
|
printer_default,
|
|
54
67
|
setTunnelLoggingEnabled
|
|
55
|
-
} from "./chunk-
|
|
68
|
+
} from "./chunk-QNUVQ6KD.js";
|
|
56
69
|
import {
|
|
57
70
|
configureLogger,
|
|
58
71
|
enablePackageLogging,
|
|
59
72
|
getLogLevel,
|
|
60
73
|
logger,
|
|
61
74
|
setLogLevel
|
|
62
|
-
} from "./chunk-
|
|
75
|
+
} from "./chunk-MV56U3E6.js";
|
|
63
76
|
import {
|
|
64
77
|
ensurePinggyConfigDir,
|
|
65
78
|
ensurePinggyLogDir,
|
|
66
79
|
getDaemonInfoPath,
|
|
67
80
|
getDaemonLogPath,
|
|
81
|
+
getDeviceConfigPath,
|
|
68
82
|
getPinggyConfigDir,
|
|
69
83
|
getTunnelLogDir,
|
|
70
84
|
getTunnelLogPath
|
|
71
|
-
} from "./chunk-
|
|
85
|
+
} from "./chunk-AFMTIELL.js";
|
|
72
86
|
|
|
73
87
|
// src/cli/options.ts
|
|
74
88
|
var cliOptions = {
|
|
@@ -798,10 +812,10 @@ function parseArgs2(finalConfig, remainingPositionals) {
|
|
|
798
812
|
}
|
|
799
813
|
function storeJson(config, saveconf) {
|
|
800
814
|
if (saveconf) {
|
|
801
|
-
const
|
|
815
|
+
const path6 = saveconf;
|
|
802
816
|
try {
|
|
803
|
-
fs.writeFileSync(
|
|
804
|
-
logger.info(`Configuration saved to ${
|
|
817
|
+
fs.writeFileSync(path6, JSON.stringify(config, null, 2), { encoding: "utf-8", flag: "w" });
|
|
818
|
+
logger.info(`Configuration saved to ${path6}`);
|
|
805
819
|
} catch (err) {
|
|
806
820
|
const msg = err instanceof Error ? err.message : String(err);
|
|
807
821
|
logger.error("Error loading configuration:", msg);
|
|
@@ -1051,7 +1065,7 @@ async function printAlreadyRunning(client, configId, label) {
|
|
|
1051
1065
|
async function startTunnel(client, config, opts) {
|
|
1052
1066
|
const result = await client.handleStartV2(config, false, opts.mode);
|
|
1053
1067
|
const prefix = opts.label ? `[${opts.label}] ` : "";
|
|
1054
|
-
const
|
|
1068
|
+
const fail2 = (reason) => {
|
|
1055
1069
|
const msg = `${prefix}Failed to start tunnel: ${reason}`;
|
|
1056
1070
|
if (opts.onError === "fatal") {
|
|
1057
1071
|
printer_default.error(msg);
|
|
@@ -1066,11 +1080,11 @@ async function startTunnel(client, config, opts) {
|
|
|
1066
1080
|
await printAlreadyRunning(client, config.configId, opts.label);
|
|
1067
1081
|
return null;
|
|
1068
1082
|
}
|
|
1069
|
-
return
|
|
1083
|
+
return fail2(result.message);
|
|
1070
1084
|
}
|
|
1071
1085
|
const lastError = result.status?.lastError;
|
|
1072
1086
|
if (lastError?.isFatal) {
|
|
1073
|
-
return
|
|
1087
|
+
return fail2(lastError.message);
|
|
1074
1088
|
}
|
|
1075
1089
|
return result;
|
|
1076
1090
|
}
|
|
@@ -1126,7 +1140,7 @@ async function connectTui(opts) {
|
|
|
1126
1140
|
const exitMessage = opts.exitMessage || "Stopping tunnel...";
|
|
1127
1141
|
if (!noTui && process.stdin.isTTY) {
|
|
1128
1142
|
try {
|
|
1129
|
-
const { TunnelTui } = await import("./TunnelTui-
|
|
1143
|
+
const { TunnelTui } = await import("./TunnelTui-UCG2YSLX.js");
|
|
1130
1144
|
const tui = new TunnelTui({
|
|
1131
1145
|
urls,
|
|
1132
1146
|
greet,
|
|
@@ -1349,7 +1363,7 @@ async function startBackgroundTunnels(configs, values, positionals) {
|
|
|
1349
1363
|
client.close();
|
|
1350
1364
|
}
|
|
1351
1365
|
async function startAutoStartTunnels() {
|
|
1352
|
-
const { getAutoStartConfigs: getAutoStartConfigs2 } = await import("./configStore-
|
|
1366
|
+
const { getAutoStartConfigs: getAutoStartConfigs2 } = await import("./configStore-RR5ZU4E3.js");
|
|
1353
1367
|
const configs = getAutoStartConfigs2();
|
|
1354
1368
|
if (configs.length === 0) {
|
|
1355
1369
|
printer_default.warn("No configs marked for auto-start. Use: pinggy config auto <name>");
|
|
@@ -1757,218 +1771,3062 @@ function printStartHelp() {
|
|
|
1757
1771
|
console.log(" pinggy start tunnela tunnelb Start multiple tunnels");
|
|
1758
1772
|
console.log(" pinggy start --all Start all auto-start tunnels\n");
|
|
1759
1773
|
}
|
|
1774
|
+
function printDeviceHelp() {
|
|
1775
|
+
console.log("\nUsage: pinggy device <command> [options]\n");
|
|
1776
|
+
console.log("Run this machine as a Pinggy device.\n");
|
|
1777
|
+
console.log("Commands:");
|
|
1778
|
+
console.log(" login Sign this machine in with your email, then stay connected");
|
|
1779
|
+
console.log(" connect --token <TOKEN> Enrol, then stay connected until interrupted");
|
|
1780
|
+
console.log(" stop Stop the agent running in the background");
|
|
1781
|
+
console.log(" status Show the enrolment, and the agent running in the background");
|
|
1782
|
+
console.log(" remove Forget the local credential (does not delete the device)\n");
|
|
1783
|
+
console.log("Options:");
|
|
1784
|
+
console.log(" -b login and connect: run the agent in the background, in the daemon.");
|
|
1785
|
+
console.log(" It stays connected after this terminal closes");
|
|
1786
|
+
console.log(" --token <TOKEN> Device token from the dashboard");
|
|
1787
|
+
console.log(" --manage <HOST> Dashboard address. Default: dashboard.pinggy.io");
|
|
1788
|
+
console.log(" --name <NAME> login only: the device's name. Default: this machine's hostname\n");
|
|
1789
|
+
console.log("Environment:");
|
|
1790
|
+
console.log(" DEVICE_AGENT_TOKEN connect only: the device token, when --token is not given. With no");
|
|
1791
|
+
console.log(" --manage, it dials dashboard.pinggy.io. Removed once read\n");
|
|
1792
|
+
}
|
|
1760
1793
|
|
|
1761
|
-
// src/
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1794
|
+
// src/devices/deviceIdentity.ts
|
|
1795
|
+
import fs3 from "fs";
|
|
1796
|
+
var SECRET_FILE_MODE = 384;
|
|
1797
|
+
function readDeviceIdentity() {
|
|
1798
|
+
const filePath = getDeviceConfigPath();
|
|
1799
|
+
if (!fs3.existsSync(filePath)) {
|
|
1800
|
+
return null;
|
|
1766
1801
|
}
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
case "stop":
|
|
1773
|
-
await handleDaemonStop();
|
|
1774
|
-
return;
|
|
1775
|
-
case "status":
|
|
1776
|
-
handleDaemonStatus();
|
|
1777
|
-
return;
|
|
1778
|
-
case "service-install":
|
|
1779
|
-
installService();
|
|
1780
|
-
return;
|
|
1781
|
-
case "service-uninstall":
|
|
1782
|
-
uninstallService();
|
|
1783
|
-
return;
|
|
1784
|
-
default:
|
|
1785
|
-
printer_default.error(`Unknown daemon command: "${verb}"`);
|
|
1786
|
-
printDaemonHelp();
|
|
1787
|
-
return;
|
|
1802
|
+
try {
|
|
1803
|
+
return JSON.parse(fs3.readFileSync(filePath, "utf-8"));
|
|
1804
|
+
} catch (e) {
|
|
1805
|
+
logger.warn("Could not read device identity file", { error: String(e) });
|
|
1806
|
+
return null;
|
|
1788
1807
|
}
|
|
1789
1808
|
}
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
const
|
|
1801
|
-
if (
|
|
1802
|
-
|
|
1803
|
-
for (const c of autoConfigs) {
|
|
1804
|
-
printer_default.print(` ${pico2.bold(c.name)} (${c.configId.slice(0, 8)})`);
|
|
1805
|
-
}
|
|
1806
|
-
} else {
|
|
1807
|
-
printer_default.print(pico2.cyanBright("Starting daemon..."));
|
|
1809
|
+
function writeDeviceIdentity(identity) {
|
|
1810
|
+
ensurePinggyConfigDir();
|
|
1811
|
+
const filePath = getDeviceConfigPath();
|
|
1812
|
+
fs3.writeFileSync(filePath, JSON.stringify(identity, null, 2), {
|
|
1813
|
+
encoding: "utf-8",
|
|
1814
|
+
mode: SECRET_FILE_MODE
|
|
1815
|
+
});
|
|
1816
|
+
fs3.chmodSync(filePath, SECRET_FILE_MODE);
|
|
1817
|
+
}
|
|
1818
|
+
function clearDeviceIdentity() {
|
|
1819
|
+
const filePath = getDeviceConfigPath();
|
|
1820
|
+
if (!fs3.existsSync(filePath)) {
|
|
1821
|
+
return false;
|
|
1808
1822
|
}
|
|
1823
|
+
fs3.unlinkSync(filePath);
|
|
1824
|
+
return true;
|
|
1825
|
+
}
|
|
1826
|
+
function maskToken(token) {
|
|
1827
|
+
return token.length <= 8 ? "***" : `${token.slice(0, 8)}...${token.slice(-4)}`;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// src/devices/deviceAgent.ts
|
|
1831
|
+
import WebSocket from "ws";
|
|
1832
|
+
import os8 from "os";
|
|
1833
|
+
|
|
1834
|
+
// src/devices/envelope.ts
|
|
1835
|
+
import { randomUUID } from "crypto";
|
|
1836
|
+
var PROTOCOL_VERSION = 1;
|
|
1837
|
+
var CHANNEL_SYSTEM = "system";
|
|
1838
|
+
var CHANNEL_DEVICE = "device";
|
|
1839
|
+
var OP_HELLO = "hello";
|
|
1840
|
+
var OP_WELCOME = "welcome";
|
|
1841
|
+
var OP_HEARTBEAT = "heartbeat";
|
|
1842
|
+
var OP_DISCONNECT = "disconnect";
|
|
1843
|
+
var OP_INFO = "info";
|
|
1844
|
+
var OP_METRICS = "metrics";
|
|
1845
|
+
var OP_TUNNELS = "tunnels";
|
|
1846
|
+
var CHANNEL_TUNNEL = "tunnel";
|
|
1847
|
+
var OP_START = "start";
|
|
1848
|
+
var OP_STOP = "stop";
|
|
1849
|
+
var OP_RESTART = "restart";
|
|
1850
|
+
var OP_UPDATE = "update";
|
|
1851
|
+
function nowSeconds() {
|
|
1852
|
+
return Math.floor(Date.now() / 1e3);
|
|
1853
|
+
}
|
|
1854
|
+
function request(ch, op, payload) {
|
|
1855
|
+
return { v: PROTOCOL_VERSION, kind: "req", ch, op, id: randomUUID(), seq: 0, ts: nowSeconds(), payload };
|
|
1856
|
+
}
|
|
1857
|
+
function response(to, op, payload) {
|
|
1858
|
+
return { v: PROTOCOL_VERSION, kind: "res", ch: to.ch, op, id: to.id, seq: 0, ts: nowSeconds(), payload };
|
|
1859
|
+
}
|
|
1860
|
+
function event(ch, op, payload) {
|
|
1861
|
+
return { v: PROTOCOL_VERSION, kind: "event", ch, op, id: "", seq: 0, ts: nowSeconds(), payload };
|
|
1862
|
+
}
|
|
1863
|
+
function parseEnvelope(raw) {
|
|
1809
1864
|
try {
|
|
1810
|
-
const
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1865
|
+
const parsed = JSON.parse(raw);
|
|
1866
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
1867
|
+
if (typeof parsed.ch !== "string" || typeof parsed.op !== "string") return null;
|
|
1868
|
+
return parsed;
|
|
1869
|
+
} catch {
|
|
1870
|
+
return null;
|
|
1815
1871
|
}
|
|
1816
1872
|
}
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1873
|
+
|
|
1874
|
+
// src/devices/device_schema.ts
|
|
1875
|
+
import { z } from "zod";
|
|
1876
|
+
var WelcomeSchema = z.object({
|
|
1877
|
+
device_agent_id: z.string(),
|
|
1878
|
+
accepted_proto: z.number(),
|
|
1879
|
+
heartbeat_interval_seconds: z.number(),
|
|
1880
|
+
stats_interval_seconds: z.number(),
|
|
1881
|
+
server_time: z.number(),
|
|
1882
|
+
max_frame_bytes: z.number(),
|
|
1883
|
+
// Optional: a dashboard that predates terminals sends none of these.
|
|
1884
|
+
terminal_enabled: z.boolean().optional(),
|
|
1885
|
+
max_terminals_per_device: z.number().optional(),
|
|
1886
|
+
// No default on the agent. Without it, terminals stay unavailable rather than unbraked.
|
|
1887
|
+
terminal_window_bytes: z.number().optional(),
|
|
1888
|
+
// No default either. Without them the agent runs no expiry timers, and the dashboard's sweep
|
|
1889
|
+
// still ends an expired shell, only later.
|
|
1890
|
+
terminal_idle_timeout_seconds: z.number().optional(),
|
|
1891
|
+
terminal_max_session_seconds: z.number().optional(),
|
|
1892
|
+
// Optional: a dashboard older than these sends neither, and the agent prints only the device id.
|
|
1893
|
+
email: z.string().optional(),
|
|
1894
|
+
device_url: z.string().optional()
|
|
1895
|
+
});
|
|
1896
|
+
var ErrorPayloadSchema = z.object({
|
|
1897
|
+
error: z.object({
|
|
1898
|
+
code: z.string(),
|
|
1899
|
+
message: z.string()
|
|
1900
|
+
})
|
|
1901
|
+
});
|
|
1902
|
+
var DisconnectSchema = z.object({
|
|
1903
|
+
reason: z.string()
|
|
1904
|
+
});
|
|
1905
|
+
var TunnelActionSchema = z.object({
|
|
1906
|
+
tunnel_id: z.string().min(1).max(128).optional(),
|
|
1907
|
+
source: z.enum(["device", "dashboard"]).optional(),
|
|
1908
|
+
config_id: z.string().min(1).max(128).optional(),
|
|
1909
|
+
config: z.record(z.string(), z.unknown()).optional()
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
// src/devices/deviceAgentReporter.ts
|
|
1913
|
+
function printConnected(welcome) {
|
|
1914
|
+
printer_default.success(`Connected as device ${welcome.device_agent_id}`);
|
|
1915
|
+
if (welcome.email) {
|
|
1916
|
+
printer_default.print(`Signed in as ${welcome.email}`);
|
|
1821
1917
|
}
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
printer_default.
|
|
1825
|
-
} else if (result.reason === DaemonHost.APP) {
|
|
1826
|
-
printer_default.print(pico2.yellow("The Pinggy daemon is running inside the Pinggy desktop app. Please quit the Pinggy app to stop it."));
|
|
1827
|
-
} else {
|
|
1828
|
-
printer_default.error(result.error);
|
|
1918
|
+
if (welcome.device_url) {
|
|
1919
|
+
printer_default.print("Open the device in a browser:");
|
|
1920
|
+
printer_default.print(` ${welcome.device_url}`);
|
|
1829
1921
|
}
|
|
1830
1922
|
}
|
|
1831
|
-
function
|
|
1832
|
-
|
|
1833
|
-
if (!info) {
|
|
1834
|
-
printer_default.print(pico2.yellow("No daemon is running. Start with: pinggy daemon start"));
|
|
1835
|
-
return;
|
|
1836
|
-
}
|
|
1837
|
-
const uptimeSec = Math.floor((Date.now() - new Date(info.startedAt).getTime()) / 1e3);
|
|
1838
|
-
const h = Math.floor(uptimeSec / 3600);
|
|
1839
|
-
const m = Math.floor(uptimeSec % 3600 / 60);
|
|
1840
|
-
const s = uptimeSec % 60;
|
|
1841
|
-
const uptimeStr = h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
1842
|
-
printer_default.print(pico2.cyanBright("Daemon Status"));
|
|
1843
|
-
printer_default.print(` PID: ${info.pid}`);
|
|
1844
|
-
printer_default.print(` Port: ${info.port}`);
|
|
1845
|
-
printer_default.print(` Started: ${info.startedAt}`);
|
|
1846
|
-
printer_default.print(` Uptime: ${uptimeStr}`);
|
|
1847
|
-
printer_default.print(` Host: ${info.host === DaemonHost.APP ? "Pinggy app (in-process)" : "standalone (CLI)"}`);
|
|
1848
|
-
printer_default.print(` IPC: v${daemonIpcVersion(info)}`);
|
|
1849
|
-
if (!isIpcCompatible(info)) {
|
|
1850
|
-
printer_default.print(pico2.yellow(` Warning: ${ipcMismatchMessage(info)}`));
|
|
1851
|
-
}
|
|
1923
|
+
function formatSeconds(millis) {
|
|
1924
|
+
return (millis / 1e3).toFixed(1);
|
|
1852
1925
|
}
|
|
1926
|
+
var printingReporter = {
|
|
1927
|
+
connecting: (server) => printer_default.print(`Connecting to ${server}`),
|
|
1928
|
+
connected: printConnected,
|
|
1929
|
+
reconnecting: (delayMillis, attempt) => printer_default.warn(
|
|
1930
|
+
`Disconnected. Reconnecting in ${formatSeconds(delayMillis)} seconds (attempt ${attempt}).`
|
|
1931
|
+
),
|
|
1932
|
+
warn: (message) => printer_default.warn(message),
|
|
1933
|
+
failed: (error) => printer_default.error(error.message),
|
|
1934
|
+
replacingDevice: (oldDeviceAgentId) => printer_default.print(`This machine was device ${oldDeviceAgentId}. Connecting it with the token from the command.`)
|
|
1935
|
+
};
|
|
1853
1936
|
|
|
1854
|
-
// src/
|
|
1855
|
-
import
|
|
1856
|
-
|
|
1857
|
-
|
|
1937
|
+
// src/devices/collectors/systemInfo.ts
|
|
1938
|
+
import os3 from "os";
|
|
1939
|
+
var UNKNOWN_CPU_MODEL = "unknown";
|
|
1940
|
+
function collectSystemInfo() {
|
|
1941
|
+
const cpus = os3.cpus();
|
|
1942
|
+
return {
|
|
1943
|
+
hostname: os3.hostname(),
|
|
1944
|
+
os: os3.platform(),
|
|
1945
|
+
os_version: os3.release(),
|
|
1946
|
+
// os.machine() reports what uname does (x86_64, arm64, aarch64). os.arch() reports the Node
|
|
1947
|
+
// build target (x64), which is not what an operator reads on the machine itself.
|
|
1948
|
+
arch: os3.machine(),
|
|
1949
|
+
kernel: os3.type(),
|
|
1950
|
+
// os.cpus() is empty on some Android and container builds.
|
|
1951
|
+
cpu_model: cpus[0]?.model?.trim() || UNKNOWN_CPU_MODEL,
|
|
1952
|
+
cpu_cores: cpus.length,
|
|
1953
|
+
total_memory_bytes: os3.totalmem()
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// src/devices/collectors/metrics.ts
|
|
1958
|
+
import os4 from "os";
|
|
1959
|
+
var CPU_SAMPLE_WINDOW_MS = 200;
|
|
1960
|
+
function sumCpuTicks(cpus) {
|
|
1961
|
+
let idleTicks = 0;
|
|
1962
|
+
let totalTicks = 0;
|
|
1963
|
+
for (const cpu of cpus) {
|
|
1964
|
+
const { user, nice, sys, idle, irq } = cpu.times;
|
|
1965
|
+
idleTicks += idle;
|
|
1966
|
+
totalTicks += user + nice + sys + idle + irq;
|
|
1967
|
+
}
|
|
1968
|
+
return { idleTicks, totalTicks };
|
|
1969
|
+
}
|
|
1970
|
+
function cpuPercentBetween(earlier, later) {
|
|
1971
|
+
const totalDelta = later.totalTicks - earlier.totalTicks;
|
|
1972
|
+
if (totalDelta <= 0) {
|
|
1973
|
+
return 0;
|
|
1974
|
+
}
|
|
1975
|
+
const busyDelta = totalDelta - (later.idleTicks - earlier.idleTicks);
|
|
1976
|
+
const percent = Math.min(100, Math.max(0, 100 * busyDelta / totalDelta));
|
|
1977
|
+
return Math.round(percent * 10) / 10;
|
|
1978
|
+
}
|
|
1979
|
+
function buildMetrics(readings) {
|
|
1980
|
+
const [oneMinute = 0, fiveMinutes = 0, fifteenMinutes = 0] = readings.loadAverages;
|
|
1981
|
+
return {
|
|
1982
|
+
cpu_percent: readings.cpuPercent,
|
|
1983
|
+
load_avg_1m: oneMinute,
|
|
1984
|
+
load_avg_5m: fiveMinutes,
|
|
1985
|
+
load_avg_15m: fifteenMinutes,
|
|
1986
|
+
memory_used_bytes: readings.totalMemoryBytes - readings.freeMemoryBytes,
|
|
1987
|
+
memory_total_bytes: readings.totalMemoryBytes,
|
|
1988
|
+
uptime_seconds: Math.floor(readings.uptimeSeconds),
|
|
1989
|
+
collected_at: readings.collectedAtEpochSeconds
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
async function collectMetrics() {
|
|
1993
|
+
const earlier = sumCpuTicks(os4.cpus());
|
|
1994
|
+
await new Promise((resolve) => setTimeout(resolve, CPU_SAMPLE_WINDOW_MS));
|
|
1995
|
+
const later = sumCpuTicks(os4.cpus());
|
|
1996
|
+
return buildMetrics({
|
|
1997
|
+
cpuPercent: cpuPercentBetween(earlier, later),
|
|
1998
|
+
loadAverages: os4.loadavg(),
|
|
1999
|
+
totalMemoryBytes: os4.totalmem(),
|
|
2000
|
+
freeMemoryBytes: os4.freemem(),
|
|
2001
|
+
uptimeSeconds: os4.uptime(),
|
|
2002
|
+
collectedAtEpochSeconds: Math.floor(Date.now() / 1e3)
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// src/devices/tunnels/tunnelList.ts
|
|
2007
|
+
var TUNNEL_POLL_INTERVAL_MILLIS = 5e3;
|
|
2008
|
+
var MAX_TUNNELS = 50;
|
|
2009
|
+
var MAX_SAVED_CONFIGS = 100;
|
|
2010
|
+
var ERROR_MESSAGE_MAX_LENGTH = 256;
|
|
2011
|
+
var REASON_IPC_VERSION_MISMATCH = "ipc_version_mismatch";
|
|
2012
|
+
var REASON_DAEMON_UNREACHABLE = "daemon_unreachable";
|
|
2013
|
+
var FORWARDING_TYPES = /* @__PURE__ */ new Set(["http", "tcp", "udp", "tls", "tlstcp"]);
|
|
2014
|
+
var DEFAULT_FORWARDING_TYPE = "http";
|
|
2015
|
+
var MODES = /* @__PURE__ */ new Set(["foreground", "detached"]);
|
|
2016
|
+
var STATES = new Set(Object.values(TunnelStateType));
|
|
2017
|
+
var STOPPED_STATES = /* @__PURE__ */ new Set(["closed" /* Closed */, "exited" /* Exited */]);
|
|
2018
|
+
async function readDaemonOnPort(port) {
|
|
1858
2019
|
try {
|
|
1859
|
-
await
|
|
2020
|
+
const answer = await new IPCClient(port, "cli").listTunnels();
|
|
2021
|
+
return { running: true, tunnels: Array.isArray(answer) ? answer : [] };
|
|
1860
2022
|
} catch (err) {
|
|
1861
|
-
|
|
1862
|
-
return;
|
|
2023
|
+
logger.debug("Device agent could not list the daemon's tunnels", { error: String(err) });
|
|
2024
|
+
return { running: false, reason: REASON_DAEMON_UNREACHABLE };
|
|
1863
2025
|
}
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
2026
|
+
}
|
|
2027
|
+
var machineTunnelListSource = {
|
|
2028
|
+
async readDaemon() {
|
|
2029
|
+
const info = getDaemonInfo();
|
|
2030
|
+
if (!info) {
|
|
2031
|
+
return { running: false, reason: null };
|
|
1869
2032
|
}
|
|
1870
|
-
if (
|
|
1871
|
-
|
|
1872
|
-
return;
|
|
2033
|
+
if (!isIpcCompatible(info)) {
|
|
2034
|
+
return { running: false, reason: REASON_IPC_VERSION_MISMATCH };
|
|
1873
2035
|
}
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
2036
|
+
return readDaemonOnPort(info.port);
|
|
2037
|
+
},
|
|
2038
|
+
readSavedConfigs: listSavedConfigs
|
|
2039
|
+
};
|
|
2040
|
+
function selfTunnelListSource(port) {
|
|
2041
|
+
return {
|
|
2042
|
+
readDaemon: () => readDaemonOnPort(port),
|
|
2043
|
+
readSavedConfigs: listSavedConfigs
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
function isObject(value) {
|
|
2047
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2048
|
+
}
|
|
2049
|
+
function stringOrNull(value) {
|
|
2050
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
2051
|
+
}
|
|
2052
|
+
function toEpochSeconds(value) {
|
|
2053
|
+
if (typeof value !== "string" || value === "") return null;
|
|
2054
|
+
const millis = Date.parse(value);
|
|
2055
|
+
return Number.isNaN(millis) ? null : Math.floor(millis / 1e3);
|
|
2056
|
+
}
|
|
2057
|
+
function sanitizeErrorMessage(value) {
|
|
2058
|
+
if (typeof value !== "string") return null;
|
|
2059
|
+
const printable = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").trim();
|
|
2060
|
+
return printable === "" ? null : printable.slice(0, ERROR_MESSAGE_MAX_LENGTH);
|
|
2061
|
+
}
|
|
2062
|
+
function forwardingType(value) {
|
|
2063
|
+
return typeof value === "string" && FORWARDING_TYPES.has(value) ? value : DEFAULT_FORWARDING_TYPE;
|
|
2064
|
+
}
|
|
2065
|
+
function toForwarding(forwarding) {
|
|
2066
|
+
if (typeof forwarding === "string") {
|
|
2067
|
+
if (forwarding.trim() === "") return [];
|
|
2068
|
+
const schemeMatch = /^([a-z]+):\/\/(.+)$/i.exec(forwarding.trim());
|
|
2069
|
+
if (!schemeMatch) {
|
|
2070
|
+
return [{ type: DEFAULT_FORWARDING_TYPE, local_address: forwarding.trim() }];
|
|
1887
2071
|
}
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
2072
|
+
const scheme = schemeMatch[1].toLowerCase();
|
|
2073
|
+
return [{
|
|
2074
|
+
type: scheme === "https" ? DEFAULT_FORWARDING_TYPE : forwardingType(scheme),
|
|
2075
|
+
local_address: schemeMatch[2]
|
|
2076
|
+
}];
|
|
2077
|
+
}
|
|
2078
|
+
if (!Array.isArray(forwarding)) return [];
|
|
2079
|
+
return forwarding.filter(isObject).filter((entry) => typeof entry.address === "string").map((entry) => ({ type: forwardingType(entry.type), local_address: entry.address }));
|
|
1893
2080
|
}
|
|
1894
|
-
function
|
|
1895
|
-
|
|
2081
|
+
function toFullConfig(rawConfig) {
|
|
2082
|
+
const parsed = TunnelConfigV1Schema.safeParse(rawConfig);
|
|
2083
|
+
return parsed.success ? parsed.data : null;
|
|
1896
2084
|
}
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2085
|
+
function toTunnelRow(daemonTunnel) {
|
|
2086
|
+
if (!isObject(daemonTunnel) || typeof daemonTunnel.tunnelid !== "string") return null;
|
|
2087
|
+
const config = isObject(daemonTunnel.tunnelconfig) ? daemonTunnel.tunnelconfig : {};
|
|
2088
|
+
const status = isObject(daemonTunnel.status) ? daemonTunnel.status : {};
|
|
2089
|
+
const remoteUrls = Array.isArray(daemonTunnel.remoteurls) ? daemonTunnel.remoteurls.filter((url) => typeof url === "string") : [];
|
|
2090
|
+
return {
|
|
2091
|
+
tunnel_id: daemonTunnel.tunnelid,
|
|
2092
|
+
config_id: stringOrNull(config.configId),
|
|
2093
|
+
name: stringOrNull(config.name),
|
|
2094
|
+
state: typeof status.state === "string" && STATES.has(status.state) ? status.state : "idle" /* New */,
|
|
2095
|
+
error_message: sanitizeErrorMessage(status.errormsg),
|
|
2096
|
+
remote_urls: remoteUrls,
|
|
2097
|
+
forwarding: toForwarding(config.forwarding),
|
|
2098
|
+
mode: typeof daemonTunnel.mode === "string" && MODES.has(daemonTunnel.mode) ? daemonTunnel.mode : null,
|
|
2099
|
+
created_at: toEpochSeconds(status.createdtimestamp),
|
|
2100
|
+
started_at: toEpochSeconds(status.starttimestamp),
|
|
2101
|
+
tunnel_config: toFullConfig(daemonTunnel.tunnelconfig)
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
function newestFirst(rows, timeOf) {
|
|
2105
|
+
return [...rows].sort((a, b) => (timeOf(b) ?? 0) - (timeOf(a) ?? 0));
|
|
2106
|
+
}
|
|
2107
|
+
function buildTunnelList(reading, savedConfigs, collectedAtEpochSeconds) {
|
|
2108
|
+
const allTunnels = reading.running ? newestFirst(
|
|
2109
|
+
reading.tunnels.map(toTunnelRow).filter((row) => row !== null),
|
|
2110
|
+
(row) => row.created_at
|
|
2111
|
+
) : [];
|
|
2112
|
+
const runningConfigIds = new Set(allTunnels.filter((row) => row.config_id !== null && !STOPPED_STATES.has(row.state)).map((row) => row.config_id));
|
|
2113
|
+
const allSaved = newestFirst(savedConfigs, (config) => toEpochSeconds(config.createdAt)).map((config) => ({
|
|
2114
|
+
config_id: config.configId,
|
|
2115
|
+
name: config.name,
|
|
2116
|
+
running: runningConfigIds.has(config.configId),
|
|
2117
|
+
// The same shape tunnelActions.ts sends the daemon to start this exact config.
|
|
2118
|
+
tunnel_config: { ...config.tunnelConfig, configId: config.configId, name: config.name }
|
|
2119
|
+
}));
|
|
2120
|
+
return {
|
|
2121
|
+
daemon_running: reading.running,
|
|
2122
|
+
daemon_unavailable_reason: reading.running ? null : reading.reason,
|
|
2123
|
+
tunnels: allTunnels.slice(0, MAX_TUNNELS),
|
|
2124
|
+
saved_configs: allSaved.slice(0, MAX_SAVED_CONFIGS),
|
|
2125
|
+
truncated: allTunnels.length > MAX_TUNNELS || allSaved.length > MAX_SAVED_CONFIGS,
|
|
2126
|
+
collected_at: collectedAtEpochSeconds
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
async function collectTunnelList(source = machineTunnelListSource) {
|
|
2130
|
+
const reading = await source.readDaemon();
|
|
2131
|
+
let savedConfigs = [];
|
|
1905
2132
|
try {
|
|
1906
|
-
|
|
2133
|
+
savedConfigs = source.readSavedConfigs();
|
|
1907
2134
|
} catch (err) {
|
|
1908
|
-
|
|
1909
|
-
return;
|
|
2135
|
+
logger.debug("Device agent could not read saved tunnel configs", { error: String(err) });
|
|
1910
2136
|
}
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
2137
|
+
return buildTunnelList(reading, savedConfigs, Math.floor(Date.now() / 1e3));
|
|
2138
|
+
}
|
|
2139
|
+
function changeKey(list) {
|
|
2140
|
+
const { collected_at: _collectedAt, ...comparable } = list;
|
|
2141
|
+
return JSON.stringify(comparable);
|
|
2142
|
+
}
|
|
2143
|
+
function startTunnelReporting(collect, send, intervalMillis = TUNNEL_POLL_INTERVAL_MILLIS) {
|
|
2144
|
+
let stopped = false;
|
|
2145
|
+
let inFlight = false;
|
|
2146
|
+
let pollAgain = false;
|
|
2147
|
+
let lastSentKey = null;
|
|
2148
|
+
const poll = () => {
|
|
2149
|
+
if (stopped) return;
|
|
2150
|
+
if (inFlight) {
|
|
2151
|
+
pollAgain = true;
|
|
1920
2152
|
return;
|
|
1921
2153
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
if (
|
|
1925
|
-
|
|
1926
|
-
|
|
2154
|
+
inFlight = true;
|
|
2155
|
+
collect().then((list) => {
|
|
2156
|
+
if (stopped) return;
|
|
2157
|
+
const key = changeKey(list);
|
|
2158
|
+
if (key === lastSentKey) return;
|
|
2159
|
+
lastSentKey = key;
|
|
2160
|
+
send(list);
|
|
2161
|
+
}).catch((err) => logger.warn("Device tunnel list collection failed", { error: String(err) })).finally(() => {
|
|
2162
|
+
inFlight = false;
|
|
2163
|
+
if (pollAgain) {
|
|
2164
|
+
pollAgain = false;
|
|
2165
|
+
poll();
|
|
1927
2166
|
}
|
|
1928
|
-
|
|
1929
|
-
|
|
2167
|
+
});
|
|
2168
|
+
};
|
|
2169
|
+
poll();
|
|
2170
|
+
const timer = setInterval(poll, intervalMillis);
|
|
2171
|
+
const stop = () => {
|
|
2172
|
+
stopped = true;
|
|
2173
|
+
clearInterval(timer);
|
|
2174
|
+
};
|
|
2175
|
+
return Object.assign(stop, { pollNow: poll });
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// src/devices/tunnels/tunnelActions.ts
|
|
2179
|
+
var ERROR_INVALID_PAYLOAD = "invalid_payload";
|
|
2180
|
+
var ERROR_UNSUPPORTED_OP = "unsupported_op";
|
|
2181
|
+
var ERROR_TUNNEL_NOT_FOUND = "tunnel_not_found";
|
|
2182
|
+
var ERROR_SAVED_CONFIG_NOT_FOUND = "saved_config_not_found";
|
|
2183
|
+
var ERROR_SAVED_CONFIG_NAME_TAKEN = "saved_config_name_taken";
|
|
2184
|
+
var ERROR_TUNNEL_ACTION_FAILED = "tunnel_action_failed";
|
|
2185
|
+
var STATE_STARTING = "starting";
|
|
2186
|
+
var DaemonUnusableError = class extends Error {
|
|
2187
|
+
};
|
|
2188
|
+
var machineTunnelActionDependencies = {
|
|
2189
|
+
async daemon(startIfMissing) {
|
|
2190
|
+
const info = getDaemonInfo();
|
|
2191
|
+
if (info) {
|
|
2192
|
+
if (!isIpcCompatible(info)) {
|
|
2193
|
+
throw new DaemonUnusableError(ipcMismatchMessage(info));
|
|
2194
|
+
}
|
|
2195
|
+
return new IPCClient(info.port, "device");
|
|
1930
2196
|
}
|
|
1931
|
-
|
|
1932
|
-
|
|
2197
|
+
if (!startIfMissing) {
|
|
2198
|
+
return null;
|
|
1933
2199
|
}
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
}
|
|
1937
|
-
|
|
2200
|
+
const started = await ensureDaemonRunning();
|
|
2201
|
+
return new IPCClient(started.port, "device");
|
|
2202
|
+
},
|
|
2203
|
+
readSavedConfigs: listSavedConfigs,
|
|
2204
|
+
writeSavedConfig: (saved) => {
|
|
2205
|
+
upsertConfig(saved);
|
|
1938
2206
|
}
|
|
2207
|
+
};
|
|
2208
|
+
function selfTunnelActionDependencies(port) {
|
|
2209
|
+
return {
|
|
2210
|
+
daemon: () => Promise.resolve(new IPCClient(port, "device")),
|
|
2211
|
+
readSavedConfigs: machineTunnelActionDependencies.readSavedConfigs,
|
|
2212
|
+
writeSavedConfig: machineTunnelActionDependencies.writeSavedConfig
|
|
2213
|
+
};
|
|
1939
2214
|
}
|
|
1940
|
-
function
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
2215
|
+
function failure(code, message) {
|
|
2216
|
+
return { error: { code, message } };
|
|
2217
|
+
}
|
|
2218
|
+
function actionFailed(message) {
|
|
2219
|
+
return failure(ERROR_TUNNEL_ACTION_FAILED, sanitizeErrorMessage(message) ?? "The tunnel action failed.");
|
|
2220
|
+
}
|
|
2221
|
+
var NO_DAEMON = failure(ERROR_TUNNEL_NOT_FOUND, "No tunnel daemon is running on this machine.");
|
|
2222
|
+
function toResult(daemonAnswer, fallbackTunnelId) {
|
|
2223
|
+
if (isErrorResponse(daemonAnswer)) {
|
|
2224
|
+
if (daemonAnswer.code === ErrorCode.TunnelNotFound) {
|
|
2225
|
+
return failure(ERROR_TUNNEL_NOT_FOUND, sanitizeErrorMessage(daemonAnswer.message) ?? "No such tunnel.");
|
|
2226
|
+
}
|
|
2227
|
+
return actionFailed(daemonAnswer.message);
|
|
1945
2228
|
}
|
|
1946
|
-
const
|
|
1947
|
-
const
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
if (tunnel) matched.push({ input, tunnel });
|
|
1951
|
-
else missing.push(input);
|
|
2229
|
+
const answer = typeof daemonAnswer === "object" && daemonAnswer !== null ? daemonAnswer : {};
|
|
2230
|
+
const tunnelId = typeof answer.tunnelid === "string" && answer.tunnelid !== "" ? answer.tunnelid : fallbackTunnelId;
|
|
2231
|
+
if (!tunnelId) {
|
|
2232
|
+
return actionFailed("The daemon did not name the tunnel.");
|
|
1952
2233
|
}
|
|
1953
|
-
|
|
2234
|
+
const state = typeof answer.status?.state === "string" ? answer.status.state : STATE_STARTING;
|
|
2235
|
+
return { tunnel_id: tunnelId, state };
|
|
1954
2236
|
}
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
2237
|
+
function withoutNulls(config) {
|
|
2238
|
+
return Object.fromEntries(Object.entries(config).filter(([, value]) => value !== null));
|
|
2239
|
+
}
|
|
2240
|
+
function targetOf(op, action) {
|
|
2241
|
+
const namesTunnel = action.tunnel_id !== void 0;
|
|
2242
|
+
if (op === OP_UPDATE) {
|
|
2243
|
+
if (namesTunnel || action.config === void 0) {
|
|
2244
|
+
return null;
|
|
2245
|
+
}
|
|
2246
|
+
if (action.source === "dashboard" && action.config_id === void 0) {
|
|
2247
|
+
return { kind: "dashboard", config: action.config };
|
|
2248
|
+
}
|
|
2249
|
+
if (action.source === "device" && action.config_id !== void 0) {
|
|
2250
|
+
return { kind: "savedEdit", configId: action.config_id, config: action.config };
|
|
2251
|
+
}
|
|
2252
|
+
return null;
|
|
1962
2253
|
}
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
try {
|
|
1966
|
-
await client.ensureDaemon();
|
|
1967
|
-
} catch (err) {
|
|
1968
|
-
printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
|
|
1969
|
-
return;
|
|
2254
|
+
if (op !== OP_START) {
|
|
2255
|
+
return namesTunnel ? { kind: "tunnel", tunnelId: action.tunnel_id } : null;
|
|
1970
2256
|
}
|
|
1971
|
-
|
|
2257
|
+
if (namesTunnel) {
|
|
2258
|
+
return action.source === void 0 && action.config_id === void 0 && action.config === void 0 ? { kind: "tunnel", tunnelId: action.tunnel_id } : null;
|
|
2259
|
+
}
|
|
2260
|
+
if (action.source === "device" && action.config_id !== void 0 && action.config === void 0) {
|
|
2261
|
+
return { kind: "saved", configId: action.config_id };
|
|
2262
|
+
}
|
|
2263
|
+
if (action.source === "dashboard" && action.config !== void 0 && action.config_id === void 0) {
|
|
2264
|
+
return { kind: "dashboard", config: action.config };
|
|
2265
|
+
}
|
|
2266
|
+
return null;
|
|
2267
|
+
}
|
|
2268
|
+
async function perform(op, action, dependencies) {
|
|
2269
|
+
const target = targetOf(op, action);
|
|
2270
|
+
if (!target) {
|
|
2271
|
+
return failure(ERROR_INVALID_PAYLOAD, "The tunnel request names no tunnel or config.");
|
|
2272
|
+
}
|
|
2273
|
+
if (target.kind === "tunnel") {
|
|
2274
|
+
const client2 = await dependencies.daemon(false);
|
|
2275
|
+
if (!client2) {
|
|
2276
|
+
return NO_DAEMON;
|
|
2277
|
+
}
|
|
2278
|
+
const answer = op === OP_STOP ? await client2.stopTunnel(target.tunnelId) : await client2.restartTunnel(target.tunnelId, true);
|
|
2279
|
+
return toResult(answer, target.tunnelId);
|
|
2280
|
+
}
|
|
2281
|
+
if (target.kind === "savedEdit") {
|
|
2282
|
+
return rewriteSavedConfig(target.configId, target.config, dependencies);
|
|
2283
|
+
}
|
|
2284
|
+
let config;
|
|
2285
|
+
if (target.kind === "saved") {
|
|
2286
|
+
const saved = dependencies.readSavedConfigs().find((candidate) => candidate.configId === target.configId);
|
|
2287
|
+
if (!saved) {
|
|
2288
|
+
return failure(ERROR_SAVED_CONFIG_NOT_FOUND, "No config with that id is saved on this machine.");
|
|
2289
|
+
}
|
|
2290
|
+
config = { ...saved.tunnelConfig, configId: saved.configId, name: saved.name };
|
|
2291
|
+
} else {
|
|
2292
|
+
const parsed = TunnelConfigV1Schema.safeParse(withoutNulls(target.config));
|
|
2293
|
+
if (!parsed.success) {
|
|
2294
|
+
return failure(ERROR_INVALID_PAYLOAD, "The dashboard config could not be read.");
|
|
2295
|
+
}
|
|
2296
|
+
config = parsed.data;
|
|
2297
|
+
}
|
|
2298
|
+
if (op === OP_UPDATE) {
|
|
2299
|
+
return updateTunnel(config, dependencies);
|
|
2300
|
+
}
|
|
2301
|
+
const client = await dependencies.daemon(true);
|
|
2302
|
+
if (!client) {
|
|
2303
|
+
return actionFailed("The tunnel daemon could not be started.");
|
|
2304
|
+
}
|
|
2305
|
+
return toResult(await client.startTunnelWithConfig(config, SessionMode.Detached, true), null);
|
|
2306
|
+
}
|
|
2307
|
+
function rewriteSavedConfig(configId, rawConfig, dependencies) {
|
|
2308
|
+
const parsed = TunnelConfigV1Schema.safeParse(withoutNulls(rawConfig));
|
|
2309
|
+
if (!parsed.success) {
|
|
2310
|
+
return failure(ERROR_INVALID_PAYLOAD, "The config could not be read.");
|
|
2311
|
+
}
|
|
2312
|
+
const savedConfigs = dependencies.readSavedConfigs();
|
|
2313
|
+
const saved = savedConfigs.find((candidate) => candidate.configId === configId);
|
|
2314
|
+
if (!saved) {
|
|
2315
|
+
return failure(ERROR_SAVED_CONFIG_NOT_FOUND, "No config with that id is saved on this machine.");
|
|
2316
|
+
}
|
|
2317
|
+
const name = parsed.data.name || saved.name;
|
|
2318
|
+
if (name !== saved.name) {
|
|
2319
|
+
const nameError = validateNameStrict(name);
|
|
2320
|
+
if (nameError) {
|
|
2321
|
+
return failure(ERROR_INVALID_PAYLOAD, nameError.message);
|
|
2322
|
+
}
|
|
2323
|
+
if (savedConfigs.some((candidate) => candidate.name === name)) {
|
|
2324
|
+
return failure(ERROR_SAVED_CONFIG_NAME_TAKEN, `A config named "${name}" is already saved on this machine.`);
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
dependencies.writeSavedConfig({
|
|
2328
|
+
...saved,
|
|
2329
|
+
name,
|
|
2330
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2331
|
+
tunnelConfig: { ...parsed.data, configId, name }
|
|
2332
|
+
});
|
|
2333
|
+
return { config_id: configId };
|
|
2334
|
+
}
|
|
2335
|
+
async function updateTunnel(config, dependencies) {
|
|
2336
|
+
const client = await dependencies.daemon(false);
|
|
2337
|
+
if (!client) {
|
|
2338
|
+
return NO_DAEMON;
|
|
2339
|
+
}
|
|
2340
|
+
const listed = await client.listTunnels();
|
|
2341
|
+
if (!Array.isArray(listed)) {
|
|
2342
|
+
return actionFailed(isErrorResponse(listed) ? listed.message : "The daemon did not list its tunnels.");
|
|
2343
|
+
}
|
|
2344
|
+
const running = listed.find((candidate) => candidate.tunnelconfig?.configId === config.configId);
|
|
2345
|
+
if (!running) {
|
|
2346
|
+
return failure(ERROR_TUNNEL_NOT_FOUND, "No tunnel on this machine runs that config.");
|
|
2347
|
+
}
|
|
2348
|
+
return toResult(await client.updateConfigV2(config, true), running.tunnelid);
|
|
2349
|
+
}
|
|
2350
|
+
async function handleTunnelAction(envelope, send, dependencies = machineTunnelActionDependencies) {
|
|
2351
|
+
if (envelope.kind !== "req") {
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
const op = envelope.op;
|
|
2355
|
+
if (op !== OP_START && op !== OP_STOP && op !== OP_RESTART && op !== OP_UPDATE) {
|
|
2356
|
+
send(response(envelope, op, failure(ERROR_UNSUPPORTED_OP, `Unsupported operation: tunnel/${op}`)));
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
const parsed = TunnelActionSchema.safeParse(envelope.payload);
|
|
2360
|
+
if (!parsed.success) {
|
|
2361
|
+
send(response(envelope, op, failure(ERROR_INVALID_PAYLOAD, "The tunnel request could not be read.")));
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
let result;
|
|
2365
|
+
try {
|
|
2366
|
+
result = await perform(op, parsed.data, dependencies);
|
|
2367
|
+
} catch (err) {
|
|
2368
|
+
result = actionFailed(err instanceof Error ? err.message : String(err));
|
|
2369
|
+
}
|
|
2370
|
+
logger.info("Device agent tunnel action", {
|
|
2371
|
+
op,
|
|
2372
|
+
tunnelId: parsed.data.tunnel_id ?? null,
|
|
2373
|
+
configId: parsed.data.config_id ?? null,
|
|
2374
|
+
source: parsed.data.source ?? null,
|
|
2375
|
+
outcome: "error" in result ? result.error.code : "accepted"
|
|
2376
|
+
});
|
|
2377
|
+
send(response(envelope, op, result));
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
// src/devices/reconnect.ts
|
|
2381
|
+
var INITIAL_DELAY_MILLIS = 1e3;
|
|
2382
|
+
var MAX_DELAY_MILLIS = 6e4;
|
|
2383
|
+
var STABLE_CONNECTION_MILLIS = 6e4;
|
|
2384
|
+
var MAX_ATTEMPT = Math.ceil(Math.log2(MAX_DELAY_MILLIS / INITIAL_DELAY_MILLIS));
|
|
2385
|
+
function backoffCeilingMillis(attempt) {
|
|
2386
|
+
const uncapped = INITIAL_DELAY_MILLIS * 2 ** Math.max(0, attempt);
|
|
2387
|
+
return Math.min(uncapped, MAX_DELAY_MILLIS);
|
|
2388
|
+
}
|
|
2389
|
+
function fullJitterMillis(ceilingMillis, random) {
|
|
2390
|
+
return Math.floor(random() * ceilingMillis);
|
|
2391
|
+
}
|
|
2392
|
+
var ReconnectPolicy = class {
|
|
2393
|
+
constructor(options = {}) {
|
|
2394
|
+
this.attemptCount = 0;
|
|
2395
|
+
this.connectedAtMillis = null;
|
|
2396
|
+
this.now = options.now ?? Date.now;
|
|
2397
|
+
this.random = options.random ?? Math.random;
|
|
2398
|
+
}
|
|
2399
|
+
/** Attempts taken since the last reset. Exposed for logging and for tests. */
|
|
2400
|
+
get attempt() {
|
|
2401
|
+
return this.attemptCount;
|
|
2402
|
+
}
|
|
2403
|
+
/** The ceiling the next delay will be drawn from, before jitter. */
|
|
2404
|
+
get nextCeilingMillis() {
|
|
2405
|
+
return backoffCeilingMillis(this.attemptCount);
|
|
2406
|
+
}
|
|
2407
|
+
markConnected() {
|
|
2408
|
+
this.connectedAtMillis = this.now();
|
|
2409
|
+
}
|
|
2410
|
+
/**
|
|
2411
|
+
* Ends the current connection. Returns true when it had held long enough to reset the
|
|
2412
|
+
* schedule, which the caller logs.
|
|
2413
|
+
*/
|
|
2414
|
+
markDisconnected() {
|
|
2415
|
+
const heldMillis = this.connectedAtMillis === null ? 0 : this.now() - this.connectedAtMillis;
|
|
2416
|
+
this.connectedAtMillis = null;
|
|
2417
|
+
const wasStable = heldMillis >= STABLE_CONNECTION_MILLIS;
|
|
2418
|
+
if (wasStable) {
|
|
2419
|
+
this.attemptCount = 0;
|
|
2420
|
+
}
|
|
2421
|
+
return wasStable;
|
|
2422
|
+
}
|
|
2423
|
+
/** The delay before the next dial, and advances the schedule. */
|
|
2424
|
+
nextDelayMillis() {
|
|
2425
|
+
const delay = fullJitterMillis(this.nextCeilingMillis, this.random);
|
|
2426
|
+
this.attemptCount = Math.min(this.attemptCount + 1, MAX_ATTEMPT);
|
|
2427
|
+
return delay;
|
|
2428
|
+
}
|
|
2429
|
+
};
|
|
2430
|
+
|
|
2431
|
+
// src/devices/terminal/terminal_schema.ts
|
|
2432
|
+
import { z as z2 } from "zod";
|
|
2433
|
+
var CHANNEL_TERMINAL = "terminal";
|
|
2434
|
+
var OP_OPEN = "open";
|
|
2435
|
+
var OP_OPENED = "opened";
|
|
2436
|
+
var OP_CLOSE = "close";
|
|
2437
|
+
var OP_DATA = "data";
|
|
2438
|
+
var OP_ACK = "ack";
|
|
2439
|
+
var OP_EXIT = "exit";
|
|
2440
|
+
var OP_RESIZE = "resize";
|
|
2441
|
+
var OP_SIGNAL = "signal";
|
|
2442
|
+
var OP_CONTEXT = "context";
|
|
2443
|
+
var OP_SNAPSHOT = "snapshot";
|
|
2444
|
+
var TERMINAL_SIGNALS = ["INT", "TERM", "QUIT", "HUP"];
|
|
2445
|
+
var MIN_GRID = 1;
|
|
2446
|
+
var MAX_GRID = 1e3;
|
|
2447
|
+
var CLOSE_REASON_USER_CLOSED = "user_closed";
|
|
2448
|
+
var CLOSE_REASON_IDLE_TIMEOUT = "idle_timeout";
|
|
2449
|
+
var CLOSE_REASON_MAX_SESSION = "max_session";
|
|
2450
|
+
var ERROR_INVALID_PAYLOAD2 = "invalid_payload";
|
|
2451
|
+
var ERROR_TERMINAL_LIMIT_REACHED = "terminal_limit_reached";
|
|
2452
|
+
var ERROR_TERMINAL_DISABLED = "terminal_disabled";
|
|
2453
|
+
var ERROR_SHELL_NOT_ALLOWED = "shell_not_allowed";
|
|
2454
|
+
var ERROR_SPAWN_FAILED = "spawn_failed";
|
|
2455
|
+
var TerminalOpenSchema = z2.object({
|
|
2456
|
+
terminal_id: z2.string().min(1),
|
|
2457
|
+
cols: z2.number().int().optional(),
|
|
2458
|
+
rows: z2.number().int().optional(),
|
|
2459
|
+
shell: z2.string().nullable().optional(),
|
|
2460
|
+
cwd: z2.string().nullable().optional()
|
|
2461
|
+
});
|
|
2462
|
+
var TerminalCloseSchema = z2.object({
|
|
2463
|
+
terminal_id: z2.string().min(1),
|
|
2464
|
+
reason: z2.string().optional()
|
|
2465
|
+
});
|
|
2466
|
+
var TerminalResizeSchema = z2.object({
|
|
2467
|
+
terminal_id: z2.string().min(1),
|
|
2468
|
+
cols: z2.number().int(),
|
|
2469
|
+
rows: z2.number().int()
|
|
2470
|
+
});
|
|
2471
|
+
var TerminalInputSchema = z2.object({
|
|
2472
|
+
terminal_id: z2.string().min(1),
|
|
2473
|
+
data: z2.string()
|
|
2474
|
+
});
|
|
2475
|
+
var TerminalSignalSchema = z2.object({
|
|
2476
|
+
terminal_id: z2.string().min(1),
|
|
2477
|
+
signal: z2.enum(TERMINAL_SIGNALS)
|
|
2478
|
+
});
|
|
2479
|
+
var TerminalAckSchema = z2.object({
|
|
2480
|
+
terminal_id: z2.string().min(1),
|
|
2481
|
+
ack_seq: z2.number().optional(),
|
|
2482
|
+
ack_bytes: z2.number()
|
|
2483
|
+
});
|
|
2484
|
+
var TerminalSnapshotRequestSchema = z2.object({
|
|
2485
|
+
terminal_id: z2.string().min(1),
|
|
2486
|
+
snapshot_id: z2.string().min(1)
|
|
2487
|
+
});
|
|
2488
|
+
function clampGrid(requested, fallback) {
|
|
2489
|
+
if (requested === void 0 || !Number.isFinite(requested)) return fallback;
|
|
2490
|
+
return Math.max(MIN_GRID, Math.min(MAX_GRID, Math.trunc(requested)));
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// src/devices/terminal/terminalHandler.ts
|
|
2494
|
+
import os6 from "os";
|
|
2495
|
+
|
|
2496
|
+
// src/devices/terminal/flowWindow.ts
|
|
2497
|
+
var FlowWindow = class {
|
|
2498
|
+
constructor(windowBytes) {
|
|
2499
|
+
this.sentBytes = 0;
|
|
2500
|
+
this.ackedBytes = 0;
|
|
2501
|
+
if (!Number.isFinite(windowBytes) || windowBytes <= 0) {
|
|
2502
|
+
throw new Error("A terminal flow window needs a positive size from welcome");
|
|
2503
|
+
}
|
|
2504
|
+
this.windowBytes = Math.trunc(windowBytes);
|
|
2505
|
+
}
|
|
2506
|
+
/** How many more raw bytes may go out before the browser has to catch up. */
|
|
2507
|
+
remaining() {
|
|
2508
|
+
return Math.max(0, this.windowBytes - (this.sentBytes - this.ackedBytes));
|
|
2509
|
+
}
|
|
2510
|
+
/** False means stop reading the pty. Nothing else is an acceptable response. */
|
|
2511
|
+
isOpen() {
|
|
2512
|
+
return this.remaining() > 0;
|
|
2513
|
+
}
|
|
2514
|
+
recordSent(byteCount) {
|
|
2515
|
+
if (!Number.isFinite(byteCount) || byteCount <= 0) return;
|
|
2516
|
+
this.sentBytes += Math.trunc(byteCount);
|
|
2517
|
+
}
|
|
2518
|
+
/**
|
|
2519
|
+
* Cumulative totals, never increments, so a dropped ack self-heals on the next one.
|
|
2520
|
+
*
|
|
2521
|
+
* A browser is not a trusted input. An ack beyond what was sent is clamped rather than believed,
|
|
2522
|
+
* and one that arrives out of order never moves the counter backwards.
|
|
2523
|
+
*/
|
|
2524
|
+
recordAck(ackBytes) {
|
|
2525
|
+
if (!Number.isFinite(ackBytes)) return;
|
|
2526
|
+
const clamped = Math.min(Math.max(Math.trunc(ackBytes), 0), this.sentBytes);
|
|
2527
|
+
if (clamped > this.ackedBytes) {
|
|
2528
|
+
this.ackedBytes = clamped;
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
get sent() {
|
|
2532
|
+
return this.sentBytes;
|
|
2533
|
+
}
|
|
2534
|
+
get acked() {
|
|
2535
|
+
return this.ackedBytes;
|
|
2536
|
+
}
|
|
2537
|
+
get size() {
|
|
2538
|
+
return this.windowBytes;
|
|
2539
|
+
}
|
|
2540
|
+
};
|
|
2541
|
+
|
|
2542
|
+
// src/devices/terminal/frameSplitter.ts
|
|
2543
|
+
var ENVELOPE_OVERHEAD_BYTES = 512;
|
|
2544
|
+
var BASE64_NUMERATOR = 3;
|
|
2545
|
+
var BASE64_DENOMINATOR = 4;
|
|
2546
|
+
var BUNDLE_MILLIS = 8;
|
|
2547
|
+
function rawBundleBytes(maxFrameBytes) {
|
|
2548
|
+
const available = maxFrameBytes - ENVELOPE_OVERHEAD_BYTES;
|
|
2549
|
+
if (available <= 0) {
|
|
2550
|
+
throw new Error("max_frame_bytes leaves no room for a terminal payload");
|
|
2551
|
+
}
|
|
2552
|
+
return Math.floor(available * BASE64_NUMERATOR / BASE64_DENOMINATOR);
|
|
2553
|
+
}
|
|
2554
|
+
var FrameSplitter = class {
|
|
2555
|
+
constructor(emit, bundleBytes, bundleMillis = BUNDLE_MILLIS) {
|
|
2556
|
+
this.emit = emit;
|
|
2557
|
+
this.bundleBytes = bundleBytes;
|
|
2558
|
+
this.bundleMillis = bundleMillis;
|
|
2559
|
+
this.pending = [];
|
|
2560
|
+
this.pendingBytes = 0;
|
|
2561
|
+
if (!Number.isFinite(bundleBytes) || bundleBytes <= 0) {
|
|
2562
|
+
throw new Error("A frame splitter needs a positive bundle size");
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
/** A chunk over the cap is split across frames, never truncated. */
|
|
2566
|
+
push(chunk) {
|
|
2567
|
+
if (chunk.length === 0) return;
|
|
2568
|
+
this.pending.push(chunk);
|
|
2569
|
+
this.pendingBytes += chunk.length;
|
|
2570
|
+
while (this.pendingBytes >= this.bundleBytes) {
|
|
2571
|
+
this.emitBytes(this.bundleBytes);
|
|
2572
|
+
}
|
|
2573
|
+
if (this.pendingBytes > 0) {
|
|
2574
|
+
this.arm();
|
|
2575
|
+
} else {
|
|
2576
|
+
this.disarm();
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
/** Sends whatever is held, if anything. Used when the terminal closes and by the timer. */
|
|
2580
|
+
flush() {
|
|
2581
|
+
this.disarm();
|
|
2582
|
+
if (this.pendingBytes > 0) {
|
|
2583
|
+
this.emitBytes(this.pendingBytes);
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
dispose() {
|
|
2587
|
+
this.disarm();
|
|
2588
|
+
this.pending = [];
|
|
2589
|
+
this.pendingBytes = 0;
|
|
2590
|
+
}
|
|
2591
|
+
get buffered() {
|
|
2592
|
+
return this.pendingBytes;
|
|
2593
|
+
}
|
|
2594
|
+
emitBytes(byteCount) {
|
|
2595
|
+
const joined = Buffer.concat(this.pending, this.pendingBytes);
|
|
2596
|
+
const frame = joined.subarray(0, byteCount);
|
|
2597
|
+
const rest = joined.subarray(byteCount);
|
|
2598
|
+
this.pending = rest.length > 0 ? [rest] : [];
|
|
2599
|
+
this.pendingBytes = rest.length;
|
|
2600
|
+
this.emit(frame);
|
|
2601
|
+
}
|
|
2602
|
+
arm() {
|
|
2603
|
+
if (this.timer !== void 0) return;
|
|
2604
|
+
this.timer = setTimeout(() => {
|
|
2605
|
+
this.timer = void 0;
|
|
2606
|
+
this.flush();
|
|
2607
|
+
}, this.bundleMillis);
|
|
2608
|
+
this.timer.unref?.();
|
|
2609
|
+
}
|
|
2610
|
+
disarm() {
|
|
2611
|
+
if (this.timer === void 0) return;
|
|
2612
|
+
clearTimeout(this.timer);
|
|
2613
|
+
this.timer = void 0;
|
|
2614
|
+
}
|
|
2615
|
+
};
|
|
2616
|
+
|
|
2617
|
+
// src/devices/terminal/shellContext.ts
|
|
2618
|
+
import { execFile } from "child_process";
|
|
2619
|
+
import fs4 from "fs";
|
|
2620
|
+
import os5 from "os";
|
|
2621
|
+
var SHELL_CONTEXT_POLL_MILLIS = 5e3;
|
|
2622
|
+
var MAX_CWD_CHARACTERS = 1024;
|
|
2623
|
+
var MAX_COMMAND_CHARACTERS = 64;
|
|
2624
|
+
var PROCESS_READ_TIMEOUT_MILLIS = 2e3;
|
|
2625
|
+
var HOME_PREFIX = "~";
|
|
2626
|
+
function processTableReader(platform2 = process.platform) {
|
|
2627
|
+
if (platform2 === "linux") return readFromProc;
|
|
2628
|
+
if (platform2 === "darwin") return readFromPsAndLsof;
|
|
2629
|
+
return null;
|
|
2630
|
+
}
|
|
2631
|
+
function programName(rawName) {
|
|
2632
|
+
const text = cleanText(rawName, Number.MAX_SAFE_INTEGER);
|
|
2633
|
+
if (text === null) return null;
|
|
2634
|
+
const firstWord = text.split(/\s+/, 1)[0];
|
|
2635
|
+
const baseName = firstWord.slice(firstWord.lastIndexOf("/") + 1).replace(/:+$/, "");
|
|
2636
|
+
return baseName === "" ? null : truncate(baseName, MAX_COMMAND_CHARACTERS);
|
|
2637
|
+
}
|
|
2638
|
+
function displayCwd(rawCwd, resolvedHome) {
|
|
2639
|
+
const cwd = cleanText(rawCwd, Number.MAX_SAFE_INTEGER);
|
|
2640
|
+
if (cwd === null) return null;
|
|
2641
|
+
let shown = cwd;
|
|
2642
|
+
if (resolvedHome && cwd === resolvedHome) {
|
|
2643
|
+
shown = HOME_PREFIX;
|
|
2644
|
+
} else if (resolvedHome && cwd.startsWith(resolvedHome + "/")) {
|
|
2645
|
+
shown = HOME_PREFIX + cwd.slice(resolvedHome.length);
|
|
2646
|
+
}
|
|
2647
|
+
return truncate(shown, MAX_CWD_CHARACTERS);
|
|
2648
|
+
}
|
|
2649
|
+
function resolvedHomeDirectory() {
|
|
2650
|
+
try {
|
|
2651
|
+
return fs4.realpathSync(os5.homedir());
|
|
2652
|
+
} catch {
|
|
2653
|
+
return null;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
function cleanText(raw, maxCharacters) {
|
|
2657
|
+
if (raw === null || raw === void 0) return null;
|
|
2658
|
+
const text = raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").trim();
|
|
2659
|
+
return text === "" ? null : truncate(text, maxCharacters);
|
|
2660
|
+
}
|
|
2661
|
+
function truncate(text, maxCharacters) {
|
|
2662
|
+
const codePoints = Array.from(text);
|
|
2663
|
+
return codePoints.length <= maxCharacters ? text : codePoints.slice(0, maxCharacters).join("");
|
|
2664
|
+
}
|
|
2665
|
+
var ShellContextTracker = class {
|
|
2666
|
+
constructor() {
|
|
2667
|
+
this.known = /* @__PURE__ */ new Map();
|
|
2668
|
+
}
|
|
2669
|
+
/** The event to send, or null when nothing changed since the last one. */
|
|
2670
|
+
observe(terminalId, raw, resolvedHome) {
|
|
2671
|
+
const previous = this.known.get(terminalId);
|
|
2672
|
+
const command = programName(raw.foregroundName);
|
|
2673
|
+
const previousCommand = previous?.command ?? null;
|
|
2674
|
+
const lastCommand = previousCommand !== null && previousCommand !== command ? previousCommand : previous?.last_command ?? null;
|
|
2675
|
+
const next = {
|
|
2676
|
+
terminal_id: terminalId,
|
|
2677
|
+
cwd: displayCwd(raw.cwd, resolvedHome),
|
|
2678
|
+
command,
|
|
2679
|
+
last_command: lastCommand
|
|
2680
|
+
};
|
|
2681
|
+
if (previous && previous.cwd === next.cwd && previous.command === next.command && previous.last_command === next.last_command) {
|
|
2682
|
+
return null;
|
|
2683
|
+
}
|
|
2684
|
+
this.known.set(terminalId, next);
|
|
2685
|
+
return next;
|
|
2686
|
+
}
|
|
2687
|
+
/** What was last sent for every shell, to send again after a reconnect. */
|
|
2688
|
+
current() {
|
|
2689
|
+
return [...this.known.values()];
|
|
2690
|
+
}
|
|
2691
|
+
forget(terminalId) {
|
|
2692
|
+
this.known.delete(terminalId);
|
|
2693
|
+
}
|
|
2694
|
+
clear() {
|
|
2695
|
+
this.known.clear();
|
|
2696
|
+
}
|
|
2697
|
+
};
|
|
2698
|
+
async function readFromProc(shellPids) {
|
|
2699
|
+
const contexts = /* @__PURE__ */ new Map();
|
|
2700
|
+
await Promise.all(shellPids.map(async (shellPid) => {
|
|
2701
|
+
const groups = await readProcGroups(shellPid);
|
|
2702
|
+
if (!groups) return;
|
|
2703
|
+
const cwd = await readOrNull(() => fs4.promises.readlink(`/proc/${shellPid}/cwd`));
|
|
2704
|
+
const foregroundName = groups.foregroundGroupId === groups.shellGroupId ? null : await readOrNull(() => fs4.promises.readFile(`/proc/${groups.foregroundGroupId}/comm`, "utf8"));
|
|
2705
|
+
contexts.set(shellPid, { cwd, foregroundName });
|
|
2706
|
+
}));
|
|
2707
|
+
return contexts;
|
|
2708
|
+
}
|
|
2709
|
+
async function readProcGroups(shellPid) {
|
|
2710
|
+
const stat = await readOrNull(() => fs4.promises.readFile(`/proc/${shellPid}/stat`, "utf8"));
|
|
2711
|
+
if (stat === null) return null;
|
|
2712
|
+
const afterName = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
2713
|
+
const shellGroupId = Number.parseInt(afterName[2], 10);
|
|
2714
|
+
const foregroundGroupId = Number.parseInt(afterName[5], 10);
|
|
2715
|
+
if (!(shellGroupId > 0) || !(foregroundGroupId > 0)) return null;
|
|
2716
|
+
return { shellGroupId, foregroundGroupId };
|
|
2717
|
+
}
|
|
2718
|
+
async function readFromPsAndLsof(shellPids) {
|
|
2719
|
+
const contexts = /* @__PURE__ */ new Map();
|
|
2720
|
+
if (shellPids.length === 0) return contexts;
|
|
2721
|
+
const groups = parseColumns(await run("ps", ["-o", "pid=,pgid=,tpgid=", "-p", shellPids.join(",")]));
|
|
2722
|
+
const foregroundGroupIds = [...groups.values()].filter(([shellGroupId, foregroundGroupId]) => foregroundGroupId > 0 && foregroundGroupId !== shellGroupId).map(([, foregroundGroupId]) => foregroundGroupId);
|
|
2723
|
+
const [names, cwds] = await Promise.all([
|
|
2724
|
+
foregroundGroupIds.length === 0 ? Promise.resolve(/* @__PURE__ */ new Map()) : run("ps", ["-o", "pid=,comm=", "-p", foregroundGroupIds.join(",")]).then(parseNames),
|
|
2725
|
+
run("lsof", ["-a", "-d", "cwd", "-Fpn", "-p", shellPids.join(",")]).then(parseLsofCwds)
|
|
2726
|
+
]);
|
|
2727
|
+
for (const shellPid of shellPids) {
|
|
2728
|
+
const group = groups.get(shellPid);
|
|
2729
|
+
if (!group) continue;
|
|
2730
|
+
const [shellGroupId, foregroundGroupId] = group;
|
|
2731
|
+
contexts.set(shellPid, {
|
|
2732
|
+
cwd: cwds.get(shellPid) ?? null,
|
|
2733
|
+
foregroundName: foregroundGroupId === shellGroupId ? null : names.get(foregroundGroupId) ?? null
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
return contexts;
|
|
2737
|
+
}
|
|
2738
|
+
function parseColumns(stdout) {
|
|
2739
|
+
const columns = /* @__PURE__ */ new Map();
|
|
2740
|
+
for (const line of (stdout ?? "").split("\n")) {
|
|
2741
|
+
const [pid, groupId, foregroundGroupId] = line.trim().split(/\s+/).map((field) => Number.parseInt(field, 10));
|
|
2742
|
+
if (pid > 0 && groupId > 0) columns.set(pid, [groupId, foregroundGroupId]);
|
|
2743
|
+
}
|
|
2744
|
+
return columns;
|
|
2745
|
+
}
|
|
2746
|
+
function parseNames(stdout) {
|
|
2747
|
+
const names = /* @__PURE__ */ new Map();
|
|
2748
|
+
for (const line of (stdout ?? "").split("\n")) {
|
|
2749
|
+
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
2750
|
+
if (match) names.set(Number.parseInt(match[1], 10), match[2]);
|
|
2751
|
+
}
|
|
2752
|
+
return names;
|
|
2753
|
+
}
|
|
2754
|
+
function parseLsofCwds(stdout) {
|
|
2755
|
+
const cwds = /* @__PURE__ */ new Map();
|
|
2756
|
+
let pid = 0;
|
|
2757
|
+
for (const line of (stdout ?? "").split("\n")) {
|
|
2758
|
+
if (line.startsWith("p")) pid = Number.parseInt(line.slice(1), 10);
|
|
2759
|
+
else if (line.startsWith("n") && pid > 0) cwds.set(pid, line.slice(1));
|
|
2760
|
+
}
|
|
2761
|
+
return cwds;
|
|
2762
|
+
}
|
|
2763
|
+
function run(program, args) {
|
|
2764
|
+
return new Promise((resolve) => {
|
|
2765
|
+
execFile(program, args, { timeout: PROCESS_READ_TIMEOUT_MILLIS }, (err, stdout) => {
|
|
2766
|
+
const output = String(stdout ?? "");
|
|
2767
|
+
resolve(err && output === "" ? null : output);
|
|
2768
|
+
});
|
|
2769
|
+
});
|
|
2770
|
+
}
|
|
2771
|
+
async function readOrNull(read) {
|
|
2772
|
+
try {
|
|
2773
|
+
return await read();
|
|
2774
|
+
} catch {
|
|
2775
|
+
return null;
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
// src/devices/terminal/screenMirror.ts
|
|
2780
|
+
import { createRequire } from "module";
|
|
2781
|
+
var SCROLLBACK_LINES = 1e3;
|
|
2782
|
+
var MAX_SNAPSHOT_BYTES = 1024 * 1024;
|
|
2783
|
+
var SCROLLBACK_LINES_TO_TRY = [SCROLLBACK_LINES, 200, 0];
|
|
2784
|
+
var require2 = createRequire(import.meta.url);
|
|
2785
|
+
var loadedModules;
|
|
2786
|
+
function loadModules() {
|
|
2787
|
+
if (loadedModules !== void 0) return loadedModules;
|
|
2788
|
+
try {
|
|
2789
|
+
const headless = require2("@xterm/headless");
|
|
2790
|
+
const serialize = require2("@xterm/addon-serialize");
|
|
2791
|
+
loadedModules = { Terminal: headless.Terminal, SerializeAddon: serialize.SerializeAddon };
|
|
2792
|
+
} catch (err) {
|
|
2793
|
+
logger.warn("Screen restore unavailable: the headless terminal did not load", { error: errorName(err) });
|
|
2794
|
+
loadedModules = null;
|
|
2795
|
+
}
|
|
2796
|
+
return loadedModules;
|
|
2797
|
+
}
|
|
2798
|
+
function isScreenMirrorSupported() {
|
|
2799
|
+
return loadModules() !== null;
|
|
2800
|
+
}
|
|
2801
|
+
var ScreenMirror = class _ScreenMirror {
|
|
2802
|
+
constructor(terminal, serializer) {
|
|
2803
|
+
this.terminal = terminal;
|
|
2804
|
+
this.serializer = serializer;
|
|
2805
|
+
this.disposed = false;
|
|
2806
|
+
}
|
|
2807
|
+
/** Null when the headless terminal cannot load. The shell then works as before, without restore. */
|
|
2808
|
+
static create(cols, rows) {
|
|
2809
|
+
const modules = loadModules();
|
|
2810
|
+
if (!modules) return null;
|
|
2811
|
+
const terminal = new modules.Terminal({ cols, rows, scrollback: SCROLLBACK_LINES, allowProposedApi: true });
|
|
2812
|
+
const serializer = new modules.SerializeAddon();
|
|
2813
|
+
terminal.loadAddon(serializer);
|
|
2814
|
+
return new _ScreenMirror(terminal, serializer);
|
|
2815
|
+
}
|
|
2816
|
+
/** Bytes exactly as they were sent. xterm parses them later, in order, and joins split characters. */
|
|
2817
|
+
feed(bytes) {
|
|
2818
|
+
if (!this.disposed) this.terminal.write(bytes);
|
|
2819
|
+
}
|
|
2820
|
+
resize(cols, rows) {
|
|
2821
|
+
if (!this.disposed) this.terminal.resize(cols, rows);
|
|
2822
|
+
}
|
|
2823
|
+
/**
|
|
2824
|
+
* The screen after everything fed before this call, and nothing fed after it. Null when the
|
|
2825
|
+
* mirror was disposed first.
|
|
2826
|
+
*
|
|
2827
|
+
* xterm parses writes in batches, on a timer. An empty write queued now has its callback run
|
|
2828
|
+
* after every earlier chunk is parsed and before the next one is, so serialising inside it sees
|
|
2829
|
+
* exactly what was fed before this call.
|
|
2830
|
+
*/
|
|
2831
|
+
snapshot() {
|
|
2832
|
+
return new Promise((resolve) => {
|
|
2833
|
+
if (this.disposed) {
|
|
2834
|
+
resolve(null);
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
this.terminal.write("", () => resolve(this.disposed ? null : this.serialise()));
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
dispose() {
|
|
2841
|
+
if (this.disposed) return;
|
|
2842
|
+
this.disposed = true;
|
|
2843
|
+
this.terminal.dispose();
|
|
2844
|
+
}
|
|
2845
|
+
/** Null when even the screen alone is over the cap. The tab then waits out its fallback. */
|
|
2846
|
+
serialise() {
|
|
2847
|
+
for (const scrollback of SCROLLBACK_LINES_TO_TRY) {
|
|
2848
|
+
const text = this.serializer.serialize({ scrollback });
|
|
2849
|
+
if (Buffer.byteLength(text, "utf-8") <= MAX_SNAPSHOT_BYTES) {
|
|
2850
|
+
return { text, cols: this.terminal.cols, rows: this.terminal.rows };
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
return null;
|
|
2854
|
+
}
|
|
2855
|
+
};
|
|
2856
|
+
function errorName(err) {
|
|
2857
|
+
return err instanceof Error ? err.name : typeof err;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// src/devices/terminal/terminalHandler.ts
|
|
2861
|
+
var DEFAULT_COLS = 80;
|
|
2862
|
+
var DEFAULT_ROWS = 24;
|
|
2863
|
+
var EXPIRY_CHECK_MILLIS = 5e3;
|
|
2864
|
+
var MILLIS_PER_SECOND = 1e3;
|
|
2865
|
+
var TerminalHandler = class {
|
|
2866
|
+
constructor(dependencies) {
|
|
2867
|
+
this.dependencies = dependencies;
|
|
2868
|
+
this.enabled = true;
|
|
2869
|
+
this.suspended = false;
|
|
2870
|
+
this.expiryTimer = null;
|
|
2871
|
+
this.contextTimer = null;
|
|
2872
|
+
this.contextPollInFlight = false;
|
|
2873
|
+
this.contextTracker = new ShellContextTracker();
|
|
2874
|
+
this.streams = /* @__PURE__ */ new Map();
|
|
2875
|
+
}
|
|
2876
|
+
/**
|
|
2877
|
+
* From welcome. An older dashboard sends no terminal fields, and `terminal_enabled` and the
|
|
2878
|
+
* per-device ceiling keep their defaults.
|
|
2879
|
+
*
|
|
2880
|
+
* The window has no default. A compiled-in one keeps working after the dashboard stops sending
|
|
2881
|
+
* the field, and nobody notices until a node falls over, so an agent without one refuses to open
|
|
2882
|
+
* a terminal at all.
|
|
2883
|
+
*/
|
|
2884
|
+
configure(terminalEnabled, maxTerminalsPerDevice, terminalWindowBytes, maxFrameBytes, idleTimeoutSeconds, maxSessionSeconds) {
|
|
2885
|
+
if (terminalEnabled !== void 0) this.enabled = terminalEnabled;
|
|
2886
|
+
if (maxTerminalsPerDevice !== void 0) this.dependencies.registry.setMaxTerminals(maxTerminalsPerDevice);
|
|
2887
|
+
this.windowBytes = terminalWindowBytes;
|
|
2888
|
+
this.bundleBytes = rawBundleBytes(maxFrameBytes);
|
|
2889
|
+
if (terminalWindowBytes === void 0) {
|
|
2890
|
+
logger.info("Terminals unavailable: welcome carried no terminal_window_bytes");
|
|
2891
|
+
}
|
|
2892
|
+
this.idleTimeoutMillis = positiveMillis(idleTimeoutSeconds);
|
|
2893
|
+
this.maxSessionMillis = positiveMillis(maxSessionSeconds);
|
|
2894
|
+
this.startExpiryTimer();
|
|
2895
|
+
}
|
|
2896
|
+
/**
|
|
2897
|
+
* Ends every shell past its idle timeout or its max-session cap. Runs on a timer; public so a test
|
|
2898
|
+
* decides when. The cap goes first: a shell both idle and too old is closed for the rule typing
|
|
2899
|
+
* cannot reset.
|
|
2900
|
+
*/
|
|
2901
|
+
expireShells() {
|
|
2902
|
+
if (this.suspended) return;
|
|
2903
|
+
const nowMillis = this.nowMillis();
|
|
2904
|
+
for (const [terminalId, stream] of [...this.streams.entries()]) {
|
|
2905
|
+
if (this.maxSessionMillis !== void 0 && nowMillis - stream.openedAtMillis >= this.maxSessionMillis) {
|
|
2906
|
+
this.expire(terminalId, CLOSE_REASON_MAX_SESSION);
|
|
2907
|
+
} else if (this.idleTimeoutMillis !== void 0 && nowMillis - stream.lastActivityMillis >= this.idleTimeoutMillis) {
|
|
2908
|
+
this.expire(terminalId, CLOSE_REASON_IDLE_TIMEOUT);
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
handle(envelope) {
|
|
2913
|
+
if (envelope.op === OP_OPEN) {
|
|
2914
|
+
this.open(envelope);
|
|
2915
|
+
} else if (envelope.op === OP_CLOSE) {
|
|
2916
|
+
this.closeFromDashboard(envelope);
|
|
2917
|
+
} else if (envelope.op === OP_ACK) {
|
|
2918
|
+
this.acknowledge(envelope);
|
|
2919
|
+
} else if (envelope.op === OP_RESIZE) {
|
|
2920
|
+
this.resize(envelope);
|
|
2921
|
+
} else if (envelope.op === OP_DATA) {
|
|
2922
|
+
this.input(envelope);
|
|
2923
|
+
} else if (envelope.op === OP_SIGNAL) {
|
|
2924
|
+
this.signal(envelope);
|
|
2925
|
+
} else if (envelope.op === OP_SNAPSHOT) {
|
|
2926
|
+
void this.snapshot(envelope);
|
|
2927
|
+
} else {
|
|
2928
|
+
logger.debug("Ignoring unhandled terminal op", { op: envelope.op });
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
/**
|
|
2932
|
+
* The agent is stopping: interrupted, revoked, or refused for good. Every shell dies with it.
|
|
2933
|
+
* A dropped socket is not this; it is `suspend`.
|
|
2934
|
+
*/
|
|
2935
|
+
closeAll() {
|
|
2936
|
+
this.stopExpiryTimer();
|
|
2937
|
+
this.stopContextTimer();
|
|
2938
|
+
this.contextTracker.clear();
|
|
2939
|
+
this.suspended = false;
|
|
2940
|
+
for (const stream of this.streams.values()) {
|
|
2941
|
+
stream.splitter.dispose();
|
|
2942
|
+
stream.mirror?.dispose();
|
|
2943
|
+
}
|
|
2944
|
+
this.streams.clear();
|
|
2945
|
+
this.dependencies.registry.killAll();
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* The socket dropped and the agent will redial. Every shell stops being read and keeps running.
|
|
2949
|
+
* Whatever it prints waits in the kernel, and the shell blocks once that buffer is full.
|
|
2950
|
+
*/
|
|
2951
|
+
suspend() {
|
|
2952
|
+
if (this.suspended) return;
|
|
2953
|
+
this.suspended = true;
|
|
2954
|
+
for (const [terminalId, session] of this.dependencies.registry.entries()) {
|
|
2955
|
+
try {
|
|
2956
|
+
session.pause();
|
|
2957
|
+
} catch (err) {
|
|
2958
|
+
logger.warn("Terminal pause failed", { terminal_id: terminalId, error: errorName2(err) });
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
logger.info("Terminals suspended until the dashboard is back", { count: this.dependencies.registry.size() });
|
|
2962
|
+
}
|
|
2963
|
+
/**
|
|
2964
|
+
* `welcome` arrived. Every shell the dashboard did not close is read again, except one whose
|
|
2965
|
+
* window is still full. That one resumes on the `ack` that reopens it.
|
|
2966
|
+
*/
|
|
2967
|
+
resumeAll() {
|
|
2968
|
+
if (!this.suspended) return;
|
|
2969
|
+
this.suspended = false;
|
|
2970
|
+
for (const [terminalId, session] of this.dependencies.registry.entries()) {
|
|
2971
|
+
const stream = this.streams.get(terminalId);
|
|
2972
|
+
if (stream) {
|
|
2973
|
+
for (const chunk of stream.heldBundles.splice(0)) {
|
|
2974
|
+
this.sendData(terminalId, chunk);
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
if (stream?.paused) continue;
|
|
2978
|
+
try {
|
|
2979
|
+
session.resume();
|
|
2980
|
+
} catch (err) {
|
|
2981
|
+
logger.warn("Terminal resume failed", { terminal_id: terminalId, error: errorName2(err) });
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
for (const context of this.contextTracker.current()) {
|
|
2985
|
+
if (this.streams.has(context.terminal_id)) {
|
|
2986
|
+
this.dependencies.send(event(CHANNEL_TERMINAL, OP_CONTEXT, context));
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
/**
|
|
2991
|
+
* Reads every open shell's directory and foreground program, and sends `context` for each that
|
|
2992
|
+
* changed. Runs on a timer while any shell is open; public so a test decides when. Never throws,
|
|
2993
|
+
* and never moves a shell's idle clock.
|
|
2994
|
+
*/
|
|
2995
|
+
async pollShellContexts() {
|
|
2996
|
+
const read = this.dependencies.readShellContexts;
|
|
2997
|
+
if (!read || this.suspended || this.contextPollInFlight || this.streams.size === 0) return;
|
|
2998
|
+
this.contextPollInFlight = true;
|
|
2999
|
+
try {
|
|
3000
|
+
const terminalIdsByPid = /* @__PURE__ */ new Map();
|
|
3001
|
+
for (const [terminalId, stream] of this.streams) {
|
|
3002
|
+
terminalIdsByPid.set(stream.session.pid, terminalId);
|
|
3003
|
+
}
|
|
3004
|
+
const contexts = await read([...terminalIdsByPid.keys()]);
|
|
3005
|
+
if (this.suspended) return;
|
|
3006
|
+
for (const [shellPid, raw] of contexts) {
|
|
3007
|
+
const terminalId = terminalIdsByPid.get(shellPid);
|
|
3008
|
+
if (!terminalId || !this.streams.has(terminalId)) continue;
|
|
3009
|
+
const changed = this.contextTracker.observe(terminalId, raw, this.dependencies.resolvedHome ?? null);
|
|
3010
|
+
if (changed) this.dependencies.send(event(CHANNEL_TERMINAL, OP_CONTEXT, changed));
|
|
3011
|
+
}
|
|
3012
|
+
} catch (err) {
|
|
3013
|
+
logger.debug("Shell context poll failed", { error: errorName2(err) });
|
|
3014
|
+
} finally {
|
|
3015
|
+
this.contextPollInFlight = false;
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
/** What `hello` lists: every shell still held, so the dashboard can keep it. */
|
|
3019
|
+
held() {
|
|
3020
|
+
return this.dependencies.registry.entries().map(([terminalId, session]) => ({
|
|
3021
|
+
terminal_id: terminalId,
|
|
3022
|
+
pid: session.pid,
|
|
3023
|
+
shell: session.shell,
|
|
3024
|
+
cols: session.cols,
|
|
3025
|
+
rows: session.rows
|
|
3026
|
+
}));
|
|
3027
|
+
}
|
|
3028
|
+
open(envelope) {
|
|
3029
|
+
const parsed = TerminalOpenSchema.safeParse(envelope.payload);
|
|
3030
|
+
if (!parsed.success) {
|
|
3031
|
+
this.refuse(envelope, null, ERROR_INVALID_PAYLOAD2, "open payload could not be read.");
|
|
3032
|
+
return;
|
|
3033
|
+
}
|
|
3034
|
+
const request2 = parsed.data;
|
|
3035
|
+
const { registry } = this.dependencies;
|
|
3036
|
+
if (!this.enabled || this.windowBytes === void 0 || this.bundleBytes === void 0) {
|
|
3037
|
+
this.refuse(envelope, request2.terminal_id, ERROR_TERMINAL_DISABLED, "Terminals are disabled.");
|
|
3038
|
+
return;
|
|
3039
|
+
}
|
|
3040
|
+
if (registry.has(request2.terminal_id) || registry.isFull()) {
|
|
3041
|
+
this.refuse(
|
|
3042
|
+
envelope,
|
|
3043
|
+
request2.terminal_id,
|
|
3044
|
+
ERROR_TERMINAL_LIMIT_REACHED,
|
|
3045
|
+
"Too many terminals are already open on this machine."
|
|
3046
|
+
);
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
const resolution = this.dependencies.resolveShell(request2.shell);
|
|
3050
|
+
if ("error" in resolution) {
|
|
3051
|
+
this.refuse(envelope, request2.terminal_id, ERROR_SHELL_NOT_ALLOWED, resolution.error);
|
|
3052
|
+
return;
|
|
3053
|
+
}
|
|
3054
|
+
const cols = clampGrid(request2.cols, DEFAULT_COLS);
|
|
3055
|
+
const rows = clampGrid(request2.rows, DEFAULT_ROWS);
|
|
3056
|
+
let session;
|
|
3057
|
+
try {
|
|
3058
|
+
session = this.dependencies.spawn({ shell: resolution.shell, cwd: request2.cwd, cols, rows });
|
|
3059
|
+
} catch (err) {
|
|
3060
|
+
logger.warn("Terminal spawn failed", { terminal_id: request2.terminal_id, error: errorName2(err) });
|
|
3061
|
+
this.refuse(envelope, request2.terminal_id, ERROR_SPAWN_FAILED, "The shell could not be started.");
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
if (!registry.add(request2.terminal_id, session)) {
|
|
3065
|
+
session.kill();
|
|
3066
|
+
this.refuse(
|
|
3067
|
+
envelope,
|
|
3068
|
+
request2.terminal_id,
|
|
3069
|
+
ERROR_TERMINAL_LIMIT_REACHED,
|
|
3070
|
+
"Too many terminals are already open on this machine."
|
|
3071
|
+
);
|
|
3072
|
+
return;
|
|
3073
|
+
}
|
|
3074
|
+
this.startStream(request2.terminal_id, session, this.windowBytes, this.bundleBytes);
|
|
3075
|
+
logger.info("Terminal opened", { terminal_id: request2.terminal_id, pid: session.pid });
|
|
3076
|
+
const opened = {
|
|
3077
|
+
terminal_id: request2.terminal_id,
|
|
3078
|
+
pid: session.pid,
|
|
3079
|
+
shell: session.shell,
|
|
3080
|
+
cols,
|
|
3081
|
+
rows
|
|
3082
|
+
};
|
|
3083
|
+
this.dependencies.send(response(envelope, OP_OPENED, opened));
|
|
3084
|
+
}
|
|
3085
|
+
startStream(terminalId, session, windowBytes, bundleBytes) {
|
|
3086
|
+
const openedAtMillis = this.nowMillis();
|
|
3087
|
+
const stream = {
|
|
3088
|
+
session,
|
|
3089
|
+
window: new FlowWindow(windowBytes),
|
|
3090
|
+
splitter: new FrameSplitter((chunk) => this.sendData(terminalId, chunk), bundleBytes),
|
|
3091
|
+
mirror: ScreenMirror.create(session.cols, session.rows),
|
|
3092
|
+
paused: false,
|
|
3093
|
+
sentSeq: 0,
|
|
3094
|
+
heldBundles: [],
|
|
3095
|
+
openedAtMillis,
|
|
3096
|
+
lastActivityMillis: openedAtMillis
|
|
3097
|
+
};
|
|
3098
|
+
this.streams.set(terminalId, stream);
|
|
3099
|
+
this.startContextTimer();
|
|
3100
|
+
session.onData((chunk) => {
|
|
3101
|
+
stream.lastActivityMillis = this.nowMillis();
|
|
3102
|
+
stream.splitter.push(chunk);
|
|
3103
|
+
});
|
|
3104
|
+
session.onExit((exitCode, signal) => {
|
|
3105
|
+
if (this.dependencies.registry.remove(terminalId) === void 0) return;
|
|
3106
|
+
stream.splitter.flush();
|
|
3107
|
+
stream.splitter.dispose();
|
|
3108
|
+
this.forgetStream(terminalId);
|
|
3109
|
+
logger.info("Terminal shell exited", { terminal_id: terminalId, exit_code: exitCode, signal });
|
|
3110
|
+
const exit = signal ? { terminal_id: terminalId, exit_code: null, signal: signalName(signal) } : { terminal_id: terminalId, exit_code: exitCode, signal: null };
|
|
3111
|
+
this.dependencies.send(event(CHANNEL_TERMINAL, OP_EXIT, exit));
|
|
3112
|
+
this.dependencies.send(event(
|
|
3113
|
+
CHANNEL_TERMINAL,
|
|
3114
|
+
OP_CLOSE,
|
|
3115
|
+
{ terminal_id: terminalId, reason: CLOSE_REASON_USER_CLOSED }
|
|
3116
|
+
));
|
|
3117
|
+
});
|
|
3118
|
+
}
|
|
3119
|
+
/**
|
|
3120
|
+
* 1 bundle leaves, and the window narrows by what it carried.
|
|
3121
|
+
*
|
|
3122
|
+
* The pause happens after the send rather than before. These bytes are already out of the pty,
|
|
3123
|
+
* and holding them is the buffering this whole mechanism exists to avoid.
|
|
3124
|
+
*
|
|
3125
|
+
* While the socket is down the bundle is held instead, and neither counts against the window nor
|
|
3126
|
+
* takes a seq. Sent into no socket, it would be a seq the dashboard never sees and bytes nobody
|
|
3127
|
+
* can ack, and the window would never reopen.
|
|
3128
|
+
*
|
|
3129
|
+
* The mirror is fed here, in the same tick as the seq, and nowhere else. A snapshot then holds
|
|
3130
|
+
* exactly the bundles up to its `after_seq`. Fed when the pty is read, a bundle still being cut
|
|
3131
|
+
* would be in the screen and drawn a second time when it is sent.
|
|
3132
|
+
*/
|
|
3133
|
+
sendData(terminalId, chunk) {
|
|
3134
|
+
const stream = this.streams.get(terminalId);
|
|
3135
|
+
if (!stream) return;
|
|
3136
|
+
if (this.suspended) {
|
|
3137
|
+
stream.heldBundles.push(chunk);
|
|
3138
|
+
return;
|
|
3139
|
+
}
|
|
3140
|
+
stream.sentSeq += 1;
|
|
3141
|
+
stream.mirror?.feed(chunk);
|
|
3142
|
+
const data = { terminal_id: terminalId, data: chunk.toString("base64") };
|
|
3143
|
+
this.dependencies.send({ ...event(CHANNEL_TERMINAL, OP_DATA, data), seq: stream.sentSeq });
|
|
3144
|
+
stream.window.recordSent(chunk.length);
|
|
3145
|
+
if (!stream.window.isOpen() && !stream.paused) {
|
|
3146
|
+
stream.paused = true;
|
|
3147
|
+
stream.session.pause();
|
|
3148
|
+
logger.debug("Terminal window full, reads paused", { terminal_id: terminalId });
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
acknowledge(envelope) {
|
|
3152
|
+
const parsed = TerminalAckSchema.safeParse(envelope.payload);
|
|
3153
|
+
if (!parsed.success) return;
|
|
3154
|
+
const stream = this.streams.get(parsed.data.terminal_id);
|
|
3155
|
+
if (!stream) return;
|
|
3156
|
+
stream.window.recordAck(parsed.data.ack_bytes);
|
|
3157
|
+
if (stream.paused && stream.window.isOpen()) {
|
|
3158
|
+
stream.paused = false;
|
|
3159
|
+
if (this.suspended) return;
|
|
3160
|
+
stream.session.resume();
|
|
3161
|
+
logger.debug("Terminal window reopened, reads resumed", { terminal_id: parsed.data.terminal_id });
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
closeFromDashboard(envelope) {
|
|
3165
|
+
const parsed = TerminalCloseSchema.safeParse(envelope.payload);
|
|
3166
|
+
if (!parsed.success) return;
|
|
3167
|
+
const session = this.dependencies.registry.remove(parsed.data.terminal_id);
|
|
3168
|
+
if (!session) return;
|
|
3169
|
+
const stream = this.streams.get(parsed.data.terminal_id);
|
|
3170
|
+
this.forgetStream(parsed.data.terminal_id);
|
|
3171
|
+
stream?.splitter.dispose();
|
|
3172
|
+
logger.info("Terminal closed by the dashboard", { terminal_id: parsed.data.terminal_id, pid: session.pid });
|
|
3173
|
+
try {
|
|
3174
|
+
session.kill();
|
|
3175
|
+
} catch {
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
/**
|
|
3179
|
+
* The tabs watching this shell changed size. Clamped like `open`, and a gone shell is ignored.
|
|
3180
|
+
* The pty is resized only on an actual change: every call is a `TIOCSWINSZ` and a SIGWINCH, and
|
|
3181
|
+
* a full-screen program redraws on each one.
|
|
3182
|
+
*/
|
|
3183
|
+
resize(envelope) {
|
|
3184
|
+
const parsed = TerminalResizeSchema.safeParse(envelope.payload);
|
|
3185
|
+
if (!parsed.success) return;
|
|
3186
|
+
const session = this.dependencies.registry.get(parsed.data.terminal_id);
|
|
3187
|
+
if (!session) return;
|
|
3188
|
+
this.markActive(parsed.data.terminal_id);
|
|
3189
|
+
const cols = clampGrid(parsed.data.cols, session.cols);
|
|
3190
|
+
const rows = clampGrid(parsed.data.rows, session.rows);
|
|
3191
|
+
if (cols === session.cols && rows === session.rows) return;
|
|
3192
|
+
try {
|
|
3193
|
+
session.resize(cols, rows);
|
|
3194
|
+
this.streams.get(parsed.data.terminal_id)?.mirror?.resize(cols, rows);
|
|
3195
|
+
} catch (err) {
|
|
3196
|
+
logger.debug("Terminal resize failed", { terminal_id: parsed.data.terminal_id, error: errorName2(err) });
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
/** Keystrokes, written as raw bytes. A gone shell is ignored. Never logged: this is what a person typed. */
|
|
3200
|
+
input(envelope) {
|
|
3201
|
+
const parsed = TerminalInputSchema.safeParse(envelope.payload);
|
|
3202
|
+
if (!parsed.success) return;
|
|
3203
|
+
const session = this.dependencies.registry.get(parsed.data.terminal_id);
|
|
3204
|
+
if (!session) return;
|
|
3205
|
+
this.markActive(parsed.data.terminal_id);
|
|
3206
|
+
try {
|
|
3207
|
+
session.write(Buffer.from(parsed.data.data, "base64"));
|
|
3208
|
+
} catch (err) {
|
|
3209
|
+
logger.debug("Terminal write failed", { terminal_id: parsed.data.terminal_id, error: errorName2(err) });
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
/** 1 allowlisted signal to the foreground process group. Anything else fails to parse and is dropped. */
|
|
3213
|
+
signal(envelope) {
|
|
3214
|
+
const parsed = TerminalSignalSchema.safeParse(envelope.payload);
|
|
3215
|
+
if (!parsed.success) {
|
|
3216
|
+
logger.debug("Ignoring a terminal signal outside the allowlist");
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
3219
|
+
const session = this.dependencies.registry.get(parsed.data.terminal_id);
|
|
3220
|
+
if (!session) return;
|
|
3221
|
+
this.markActive(parsed.data.terminal_id);
|
|
3222
|
+
session.signalForeground(parsed.data.signal);
|
|
3223
|
+
}
|
|
3224
|
+
markActive(terminalId) {
|
|
3225
|
+
const stream = this.streams.get(terminalId);
|
|
3226
|
+
if (stream) stream.lastActivityMillis = this.nowMillis();
|
|
3227
|
+
}
|
|
3228
|
+
/**
|
|
3229
|
+
* Removed from the registry before the kill, so its exit callback finds nothing and sends no
|
|
3230
|
+
* `user_closed`. The output already read goes out first, then `close` with the reason.
|
|
3231
|
+
*/
|
|
3232
|
+
expire(terminalId, reason) {
|
|
3233
|
+
const session = this.dependencies.registry.remove(terminalId);
|
|
3234
|
+
const stream = this.streams.get(terminalId);
|
|
3235
|
+
this.forgetStream(terminalId);
|
|
3236
|
+
stream?.splitter.flush();
|
|
3237
|
+
stream?.splitter.dispose();
|
|
3238
|
+
if (!session) return;
|
|
3239
|
+
logger.info("Terminal expired", { terminal_id: terminalId, pid: session.pid, reason });
|
|
3240
|
+
try {
|
|
3241
|
+
session.kill();
|
|
3242
|
+
} catch {
|
|
3243
|
+
}
|
|
3244
|
+
this.dependencies.send(event(CHANNEL_TERMINAL, OP_CLOSE, { terminal_id: terminalId, reason }));
|
|
3245
|
+
}
|
|
3246
|
+
/**
|
|
3247
|
+
* A tab attached and asked for the screen it missed (slice T6). Answered in parts, each 1 frame,
|
|
3248
|
+
* which the dashboard routes to that tab alone.
|
|
3249
|
+
*
|
|
3250
|
+
* `after_seq` and `after_bytes` are read in the same tick as the request, before the mirror is
|
|
3251
|
+
* asked, so they name exactly the bundles the screen holds. The parts go out even with the window
|
|
3252
|
+
* closed: they are not pty output, so they take no seq and the window never counts them. A request
|
|
3253
|
+
* for a shell that is gone, or a screen too big to send, is not answered; the tab falls back.
|
|
3254
|
+
*/
|
|
3255
|
+
async snapshot(envelope) {
|
|
3256
|
+
const parsed = TerminalSnapshotRequestSchema.safeParse(envelope.payload);
|
|
3257
|
+
if (!parsed.success || this.bundleBytes === void 0) return;
|
|
3258
|
+
const { terminal_id: terminalId, snapshot_id: snapshotId } = parsed.data;
|
|
3259
|
+
const stream = this.streams.get(terminalId);
|
|
3260
|
+
if (!stream?.mirror) return;
|
|
3261
|
+
const afterSeq = stream.sentSeq;
|
|
3262
|
+
const afterBytes = stream.window.sent;
|
|
3263
|
+
const screen = await stream.mirror.snapshot();
|
|
3264
|
+
if (!screen || this.suspended || this.streams.get(terminalId) !== stream) return;
|
|
3265
|
+
const parts = splitIntoParts(Buffer.from(screen.text, "utf-8"), this.bundleBytes);
|
|
3266
|
+
parts.forEach((part, index) => {
|
|
3267
|
+
const payload = {
|
|
3268
|
+
terminal_id: terminalId,
|
|
3269
|
+
snapshot_id: snapshotId,
|
|
3270
|
+
part: index,
|
|
3271
|
+
last: index === parts.length - 1,
|
|
3272
|
+
after_seq: afterSeq,
|
|
3273
|
+
after_bytes: afterBytes,
|
|
3274
|
+
cols: screen.cols,
|
|
3275
|
+
rows: screen.rows,
|
|
3276
|
+
data: part.toString("base64")
|
|
3277
|
+
};
|
|
3278
|
+
this.dependencies.send(event(CHANNEL_TERMINAL, OP_SNAPSHOT, payload));
|
|
3279
|
+
});
|
|
3280
|
+
logger.debug("Terminal screen sent", { terminal_id: terminalId, parts: parts.length, after_seq: afterSeq });
|
|
3281
|
+
}
|
|
3282
|
+
/** A shell is gone. The context poll stops with the last one, so an idle agent spawns nothing. */
|
|
3283
|
+
forgetStream(terminalId) {
|
|
3284
|
+
this.streams.get(terminalId)?.mirror?.dispose();
|
|
3285
|
+
this.streams.delete(terminalId);
|
|
3286
|
+
this.contextTracker.forget(terminalId);
|
|
3287
|
+
if (this.streams.size === 0) this.stopContextTimer();
|
|
3288
|
+
}
|
|
3289
|
+
startContextTimer() {
|
|
3290
|
+
if (this.contextTimer || !this.dependencies.readShellContexts) return;
|
|
3291
|
+
this.contextTimer = setInterval(() => void this.pollShellContexts(), SHELL_CONTEXT_POLL_MILLIS);
|
|
3292
|
+
this.contextTimer.unref();
|
|
3293
|
+
}
|
|
3294
|
+
stopContextTimer() {
|
|
3295
|
+
if (this.contextTimer) {
|
|
3296
|
+
clearInterval(this.contextTimer);
|
|
3297
|
+
this.contextTimer = null;
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
startExpiryTimer() {
|
|
3301
|
+
if (this.expiryTimer || this.idleTimeoutMillis === void 0 && this.maxSessionMillis === void 0) {
|
|
3302
|
+
return;
|
|
3303
|
+
}
|
|
3304
|
+
this.expiryTimer = setInterval(() => this.expireShells(), EXPIRY_CHECK_MILLIS);
|
|
3305
|
+
this.expiryTimer.unref();
|
|
3306
|
+
}
|
|
3307
|
+
stopExpiryTimer() {
|
|
3308
|
+
if (this.expiryTimer) {
|
|
3309
|
+
clearInterval(this.expiryTimer);
|
|
3310
|
+
this.expiryTimer = null;
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
nowMillis() {
|
|
3314
|
+
return this.dependencies.now ? this.dependencies.now() : Date.now();
|
|
3315
|
+
}
|
|
3316
|
+
refuse(envelope, terminalId, code, message) {
|
|
3317
|
+
logger.info("Terminal open refused", { terminal_id: terminalId, code });
|
|
3318
|
+
const refused = { terminal_id: terminalId, error: { code, message } };
|
|
3319
|
+
this.dependencies.send(response(envelope, OP_OPENED, refused));
|
|
3320
|
+
}
|
|
3321
|
+
};
|
|
3322
|
+
function positiveMillis(seconds) {
|
|
3323
|
+
return seconds !== void 0 && Number.isFinite(seconds) && seconds > 0 ? seconds * MILLIS_PER_SECOND : void 0;
|
|
3324
|
+
}
|
|
3325
|
+
function splitIntoParts(bytes, partBytes) {
|
|
3326
|
+
const parts = [];
|
|
3327
|
+
for (let offset = 0; offset < bytes.length; offset += partBytes) {
|
|
3328
|
+
parts.push(bytes.subarray(offset, offset + partBytes));
|
|
3329
|
+
}
|
|
3330
|
+
return parts.length > 0 ? parts : [Buffer.alloc(0)];
|
|
3331
|
+
}
|
|
3332
|
+
function errorName2(err) {
|
|
3333
|
+
return err instanceof Error ? err.name : typeof err;
|
|
3334
|
+
}
|
|
3335
|
+
function signalName(signal) {
|
|
3336
|
+
for (const [name, number] of Object.entries(os6.constants.signals)) {
|
|
3337
|
+
if (number === signal) return name.replace(/^SIG/, "");
|
|
3338
|
+
}
|
|
3339
|
+
return String(signal);
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
// src/devices/terminal/terminalRegistry.ts
|
|
3343
|
+
var DEFAULT_MAX_TERMINALS = 3;
|
|
3344
|
+
var TerminalRegistry = class {
|
|
3345
|
+
constructor() {
|
|
3346
|
+
this.handlesById = /* @__PURE__ */ new Map();
|
|
3347
|
+
this.maxTerminals = DEFAULT_MAX_TERMINALS;
|
|
3348
|
+
}
|
|
3349
|
+
setMaxTerminals(maxTerminals) {
|
|
3350
|
+
if (Number.isInteger(maxTerminals) && maxTerminals > 0) {
|
|
3351
|
+
this.maxTerminals = maxTerminals;
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
has(terminalId) {
|
|
3355
|
+
return this.handlesById.has(terminalId);
|
|
3356
|
+
}
|
|
3357
|
+
isFull() {
|
|
3358
|
+
return this.handlesById.size >= this.maxTerminals;
|
|
3359
|
+
}
|
|
3360
|
+
/** False when the id is already taken or the ceiling is reached. The caller then kills the handle. */
|
|
3361
|
+
add(terminalId, handle) {
|
|
3362
|
+
if (this.handlesById.has(terminalId) || this.isFull()) return false;
|
|
3363
|
+
this.handlesById.set(terminalId, handle);
|
|
3364
|
+
return true;
|
|
3365
|
+
}
|
|
3366
|
+
get(terminalId) {
|
|
3367
|
+
return this.handlesById.get(terminalId);
|
|
3368
|
+
}
|
|
3369
|
+
/** Removes and returns the handle. Undefined the second time, so a close runs at most once. */
|
|
3370
|
+
remove(terminalId) {
|
|
3371
|
+
const handle = this.handlesById.get(terminalId);
|
|
3372
|
+
this.handlesById.delete(terminalId);
|
|
3373
|
+
return handle;
|
|
3374
|
+
}
|
|
3375
|
+
/** Every held shell with its id, in the order it was added. */
|
|
3376
|
+
entries() {
|
|
3377
|
+
return [...this.handlesById.entries()];
|
|
3378
|
+
}
|
|
3379
|
+
size() {
|
|
3380
|
+
return this.handlesById.size;
|
|
3381
|
+
}
|
|
3382
|
+
/** Removes every handle before killing any, so no exit callback finds itself still registered. */
|
|
3383
|
+
killAll() {
|
|
3384
|
+
const handles = [...this.handlesById.values()];
|
|
3385
|
+
this.handlesById.clear();
|
|
3386
|
+
for (const handle of handles) {
|
|
3387
|
+
try {
|
|
3388
|
+
handle.kill();
|
|
3389
|
+
} catch {
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
};
|
|
3394
|
+
|
|
3395
|
+
// src/devices/terminal/ptySession.ts
|
|
3396
|
+
import { execFile as execFile2 } from "child_process";
|
|
3397
|
+
import fs6 from "fs";
|
|
3398
|
+
import os7 from "os";
|
|
3399
|
+
|
|
3400
|
+
// src/devices/terminal/nodePtyRuntime.ts
|
|
3401
|
+
import fs5 from "fs";
|
|
3402
|
+
import path3 from "path";
|
|
3403
|
+
import { createRequire as createRequire2 } from "module";
|
|
3404
|
+
var require3 = createRequire2(import.meta.url);
|
|
3405
|
+
var PACKAGE_NAME = "node-pty";
|
|
3406
|
+
var NATIVE_FILE = "pty.node";
|
|
3407
|
+
var SPAWN_HELPER_FILE = "spawn-helper";
|
|
3408
|
+
var EXECUTABLE_MODE = 493;
|
|
3409
|
+
var EXECUTE_BITS = 73;
|
|
3410
|
+
var RUNTIME_DIR_NAME = "runtime";
|
|
3411
|
+
var ASAR_ARCHIVE_SUFFIX = ".asar";
|
|
3412
|
+
var NATIVE_DIRECTORIES = [
|
|
3413
|
+
path3.join("build", "Release"),
|
|
3414
|
+
path3.join("prebuilds", `${process.platform}-${process.arch}`)
|
|
3415
|
+
];
|
|
3416
|
+
function isPackagedBinary() {
|
|
3417
|
+
return Boolean(process.pkg);
|
|
3418
|
+
}
|
|
3419
|
+
function isInsideAsarArchive(packageRoot) {
|
|
3420
|
+
if (!process.versions.electron) return false;
|
|
3421
|
+
return packageRoot.split(path3.sep).some((segment) => segment.endsWith(ASAR_ARCHIVE_SUFFIX));
|
|
3422
|
+
}
|
|
3423
|
+
function resolveNodePtyRoot() {
|
|
3424
|
+
const resolvedRoot = path3.dirname(require3.resolve(`${PACKAGE_NAME}/package.json`));
|
|
3425
|
+
if (!isPackagedBinary() && !isInsideAsarArchive(resolvedRoot)) return resolvedRoot;
|
|
3426
|
+
return unpackNodePty(resolvedRoot);
|
|
3427
|
+
}
|
|
3428
|
+
function requireNodePty(packageRoot) {
|
|
3429
|
+
return require3(packageRoot);
|
|
3430
|
+
}
|
|
3431
|
+
function spawnHelperPaths(packageRoot) {
|
|
3432
|
+
if (process.platform === "win32") return [];
|
|
3433
|
+
return NATIVE_DIRECTORIES.map((directory) => path3.join(packageRoot, directory, SPAWN_HELPER_FILE));
|
|
3434
|
+
}
|
|
3435
|
+
function hasExecutableSpawnHelper(packageRoot) {
|
|
3436
|
+
const helpers = spawnHelperPaths(packageRoot);
|
|
3437
|
+
if (helpers.length === 0) return true;
|
|
3438
|
+
return helpers.some((helper) => {
|
|
3439
|
+
try {
|
|
3440
|
+
const stat = fs5.statSync(helper);
|
|
3441
|
+
return stat.isFile() && (stat.mode & EXECUTE_BITS) !== 0;
|
|
3442
|
+
} catch {
|
|
3443
|
+
return false;
|
|
3444
|
+
}
|
|
3445
|
+
});
|
|
3446
|
+
}
|
|
3447
|
+
function makeSpawnHelperExecutable(packageRoot) {
|
|
3448
|
+
for (const helper of spawnHelperPaths(packageRoot)) {
|
|
3449
|
+
try {
|
|
3450
|
+
const stat = fs5.statSync(helper);
|
|
3451
|
+
if ((stat.mode & EXECUTE_BITS) === 0) {
|
|
3452
|
+
fs5.chmodSync(helper, EXECUTABLE_MODE);
|
|
3453
|
+
}
|
|
3454
|
+
} catch {
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
function unpackNodePty(snapshotRoot) {
|
|
3459
|
+
const unpackedRoot = unpackedRootPath(snapshotRoot);
|
|
3460
|
+
if (isLoadable(unpackedRoot)) return unpackedRoot;
|
|
3461
|
+
const stagingRoot = `${unpackedRoot}.${process.pid}`;
|
|
3462
|
+
fs5.rmSync(stagingRoot, { recursive: true, force: true });
|
|
3463
|
+
try {
|
|
3464
|
+
copyRuntimeFiles(snapshotRoot, stagingRoot);
|
|
3465
|
+
makeSpawnHelperExecutable(stagingRoot);
|
|
3466
|
+
fs5.renameSync(stagingRoot, unpackedRoot);
|
|
3467
|
+
} catch (error) {
|
|
3468
|
+
fs5.rmSync(stagingRoot, { recursive: true, force: true });
|
|
3469
|
+
if (!isLoadable(unpackedRoot)) throw error;
|
|
3470
|
+
}
|
|
3471
|
+
return unpackedRoot;
|
|
3472
|
+
}
|
|
3473
|
+
function unpackedRootPath(snapshotRoot) {
|
|
3474
|
+
const version = readPackageVersion(snapshotRoot);
|
|
3475
|
+
const directoryName = `${PACKAGE_NAME}-${version}-${process.platform}-${process.arch}`;
|
|
3476
|
+
return path3.join(getPinggyConfigDir(), RUNTIME_DIR_NAME, directoryName);
|
|
3477
|
+
}
|
|
3478
|
+
function readPackageVersion(packageRoot) {
|
|
3479
|
+
const manifest = JSON.parse(fs5.readFileSync(path3.join(packageRoot, "package.json"), "utf8"));
|
|
3480
|
+
return String(manifest.version);
|
|
3481
|
+
}
|
|
3482
|
+
function isLoadable(packageRoot) {
|
|
3483
|
+
if (!fs5.existsSync(path3.join(packageRoot, "package.json"))) return false;
|
|
3484
|
+
return NATIVE_DIRECTORIES.some((directory) => fs5.existsSync(path3.join(packageRoot, directory, NATIVE_FILE)));
|
|
3485
|
+
}
|
|
3486
|
+
function copyRuntimeFiles(sourceRoot, targetRoot) {
|
|
3487
|
+
fs5.mkdirSync(targetRoot, { recursive: true });
|
|
3488
|
+
copyFile(path3.join(sourceRoot, "package.json"), path3.join(targetRoot, "package.json"));
|
|
3489
|
+
copyDirectory(path3.join(sourceRoot, "lib"), path3.join(targetRoot, "lib"));
|
|
3490
|
+
for (const directory of NATIVE_DIRECTORIES) {
|
|
3491
|
+
const source = path3.join(sourceRoot, directory);
|
|
3492
|
+
if (fs5.existsSync(source)) {
|
|
3493
|
+
copyDirectory(source, path3.join(targetRoot, directory));
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
function copyDirectory(sourceDirectory, targetDirectory) {
|
|
3498
|
+
fs5.mkdirSync(targetDirectory, { recursive: true });
|
|
3499
|
+
for (const entry of fs5.readdirSync(sourceDirectory, { withFileTypes: true })) {
|
|
3500
|
+
const source = path3.join(sourceDirectory, entry.name);
|
|
3501
|
+
const target = path3.join(targetDirectory, entry.name);
|
|
3502
|
+
if (entry.isDirectory()) {
|
|
3503
|
+
copyDirectory(source, target);
|
|
3504
|
+
continue;
|
|
3505
|
+
}
|
|
3506
|
+
copyFile(source, target);
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
function copyFile(source, target) {
|
|
3510
|
+
fs5.writeFileSync(target, fs5.readFileSync(source));
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
// src/devices/terminal/ptySession.ts
|
|
3514
|
+
var loadedPty;
|
|
3515
|
+
var TERM_NAME = "xterm-256color";
|
|
3516
|
+
function foregroundProcessGroup(shellPid) {
|
|
3517
|
+
return new Promise((resolve) => {
|
|
3518
|
+
execFile2("ps", ["-o", "tpgid=", "-p", String(shellPid)], (err, stdout) => {
|
|
3519
|
+
const processGroupId = err ? NaN : Number.parseInt(String(stdout).trim(), 10);
|
|
3520
|
+
resolve(Number.isInteger(processGroupId) && processGroupId > 0 ? processGroupId : null);
|
|
3521
|
+
});
|
|
3522
|
+
});
|
|
3523
|
+
}
|
|
3524
|
+
async function signalForegroundGroup(shellPid, signal) {
|
|
3525
|
+
if (process.platform === "win32") {
|
|
3526
|
+
logger.debug("Terminal signal ignored on Windows", { pid: shellPid, signal });
|
|
3527
|
+
return;
|
|
3528
|
+
}
|
|
3529
|
+
const processGroupId = await foregroundProcessGroup(shellPid) ?? shellPid;
|
|
3530
|
+
try {
|
|
3531
|
+
process.kill(-processGroupId, `SIG${signal}`);
|
|
3532
|
+
} catch (err) {
|
|
3533
|
+
logger.debug("Terminal signal not delivered", {
|
|
3534
|
+
pid: shellPid,
|
|
3535
|
+
signal,
|
|
3536
|
+
error: err instanceof Error ? err.name : typeof err
|
|
3537
|
+
});
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
function loadNodePty() {
|
|
3541
|
+
if (loadedPty !== void 0) return loadedPty;
|
|
3542
|
+
try {
|
|
3543
|
+
const packageRoot = resolveNodePtyRoot();
|
|
3544
|
+
const pty = requireNodePty(packageRoot);
|
|
3545
|
+
ensureSpawnHelperExecutable(packageRoot);
|
|
3546
|
+
loadedPty = hasExecutableSpawnHelper(packageRoot) ? pty : null;
|
|
3547
|
+
} catch {
|
|
3548
|
+
loadedPty = null;
|
|
3549
|
+
}
|
|
3550
|
+
return loadedPty;
|
|
3551
|
+
}
|
|
3552
|
+
function isTerminalSupported() {
|
|
3553
|
+
return loadNodePty() !== null;
|
|
3554
|
+
}
|
|
3555
|
+
function ensureSpawnHelperExecutable(packageRoot) {
|
|
3556
|
+
makeSpawnHelperExecutable(packageRoot ?? resolveNodePtyRoot());
|
|
3557
|
+
}
|
|
3558
|
+
function resolveCwd(requested) {
|
|
3559
|
+
if (requested) {
|
|
3560
|
+
try {
|
|
3561
|
+
if (fs6.statSync(requested).isDirectory()) return requested;
|
|
3562
|
+
} catch {
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
return os7.homedir();
|
|
3566
|
+
}
|
|
3567
|
+
function spawnPty(request2) {
|
|
3568
|
+
const pty = loadNodePty();
|
|
3569
|
+
if (!pty) {
|
|
3570
|
+
throw new Error("node-pty is not available on this machine");
|
|
3571
|
+
}
|
|
3572
|
+
const process_ = pty.spawn(request2.shell, [], {
|
|
3573
|
+
name: TERM_NAME,
|
|
3574
|
+
cols: request2.cols,
|
|
3575
|
+
rows: request2.rows,
|
|
3576
|
+
cwd: resolveCwd(request2.cwd),
|
|
3577
|
+
env: { ...process.env, TERM: TERM_NAME },
|
|
3578
|
+
// Buffers rather than strings. node-pty decodes per read otherwise, and a read that ends
|
|
3579
|
+
// mid-character yields a replacement character the browser can never recover.
|
|
3580
|
+
encoding: null
|
|
3581
|
+
});
|
|
3582
|
+
return {
|
|
3583
|
+
pid: process_.pid,
|
|
3584
|
+
shell: request2.shell,
|
|
3585
|
+
get cols() {
|
|
3586
|
+
return process_.cols;
|
|
3587
|
+
},
|
|
3588
|
+
get rows() {
|
|
3589
|
+
return process_.rows;
|
|
3590
|
+
},
|
|
3591
|
+
kill: () => process_.kill(),
|
|
3592
|
+
onExit: (listener) => {
|
|
3593
|
+
process_.onExit(({ exitCode, signal }) => listener(exitCode, signal));
|
|
3594
|
+
},
|
|
3595
|
+
onData: (listener) => {
|
|
3596
|
+
process_.onData((chunk) => {
|
|
3597
|
+
listener(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk);
|
|
3598
|
+
});
|
|
3599
|
+
},
|
|
3600
|
+
pause: () => process_.pause(),
|
|
3601
|
+
resume: () => process_.resume(),
|
|
3602
|
+
resize: (cols, rows) => process_.resize(cols, rows),
|
|
3603
|
+
write: (bytes) => process_.write(bytes),
|
|
3604
|
+
signalForeground: (signal) => {
|
|
3605
|
+
void signalForegroundGroup(process_.pid, signal);
|
|
3606
|
+
}
|
|
3607
|
+
};
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
// src/devices/terminal/shellAllowlist.ts
|
|
3611
|
+
import fs7 from "fs";
|
|
3612
|
+
var FALLBACK_UNIX_SHELLS = ["/bin/bash", "/bin/zsh", "/bin/sh", "/usr/bin/bash", "/usr/bin/zsh"];
|
|
3613
|
+
var WINDOWS_SHELLS = ["powershell.exe", "cmd.exe"];
|
|
3614
|
+
var ETC_SHELLS_PATH = "/etc/shells";
|
|
3615
|
+
function readShellEnvironment() {
|
|
3616
|
+
let etcShells = null;
|
|
3617
|
+
try {
|
|
3618
|
+
etcShells = fs7.readFileSync(ETC_SHELLS_PATH, "utf8");
|
|
3619
|
+
} catch {
|
|
3620
|
+
etcShells = null;
|
|
3621
|
+
}
|
|
3622
|
+
return {
|
|
3623
|
+
platform: process.platform,
|
|
3624
|
+
loginShell: process.env.SHELL,
|
|
3625
|
+
etcShells,
|
|
3626
|
+
exists: (path6) => {
|
|
3627
|
+
try {
|
|
3628
|
+
return fs7.statSync(path6).isFile();
|
|
3629
|
+
} catch {
|
|
3630
|
+
return false;
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
};
|
|
3634
|
+
}
|
|
3635
|
+
function parseEtcShells(contents) {
|
|
3636
|
+
return contents.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("/"));
|
|
3637
|
+
}
|
|
3638
|
+
function allowedShells(environment) {
|
|
3639
|
+
if (environment.platform === "win32") return WINDOWS_SHELLS;
|
|
3640
|
+
const listed = environment.etcShells === null ? [] : parseEtcShells(environment.etcShells);
|
|
3641
|
+
return listed.length > 0 ? listed : FALLBACK_UNIX_SHELLS;
|
|
3642
|
+
}
|
|
3643
|
+
function resolveShell(requested, environment) {
|
|
3644
|
+
const allowed = allowedShells(environment);
|
|
3645
|
+
const usable = (shell) => environment.platform === "win32" || environment.exists(shell);
|
|
3646
|
+
if (requested !== null && requested !== void 0) {
|
|
3647
|
+
if (allowed.includes(requested) && usable(requested)) {
|
|
3648
|
+
return { shell: requested };
|
|
3649
|
+
}
|
|
3650
|
+
return { error: "That shell is not allowed on this machine." };
|
|
3651
|
+
}
|
|
3652
|
+
const loginShell = environment.loginShell;
|
|
3653
|
+
if (loginShell && allowed.includes(loginShell) && usable(loginShell)) {
|
|
3654
|
+
return { shell: loginShell };
|
|
3655
|
+
}
|
|
3656
|
+
const firstUsable = allowed.find(usable);
|
|
3657
|
+
return firstUsable ? { shell: firstUsable } : { error: "No allowed shell exists on this machine." };
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
// src/devices/deviceAgent.ts
|
|
3661
|
+
var CLOSE_CODE_REVOKED = 4001;
|
|
3662
|
+
var CLOSE_CODE_NORMAL = 1e3;
|
|
3663
|
+
var CLOSE_TIMEOUT_MILLIS = 3e3;
|
|
3664
|
+
var TOKEN_HEADER = "X-Pinggy-Device-Token";
|
|
3665
|
+
var DEFAULT_SERVER = "dashboard.pinggy.io";
|
|
3666
|
+
var ERROR_ALREADY_CONNECTED = "already_connected";
|
|
3667
|
+
var AGENT_ERROR_UNAUTHORIZED = "unauthorized";
|
|
3668
|
+
var AGENT_ERROR_HANDSHAKE_REFUSED = "handshake_refused";
|
|
3669
|
+
var AGENT_ERROR_DISCONNECTED = "disconnected";
|
|
3670
|
+
var AGENT_ERROR_NOT_CONNECTED = "not_connected";
|
|
3671
|
+
var HANDSHAKE_TIMEOUT_MILLIS = 3e4;
|
|
3672
|
+
var PONG_GRACE_INTERVALS = 2;
|
|
3673
|
+
var BASE_CAPABILITIES = ["tunnel", "stats", "tunnel_control", "tunnel_update", "saved_config_update"];
|
|
3674
|
+
var CAPABILITY_TERMINAL = "terminal";
|
|
3675
|
+
var CAPABILITY_TERMINAL_SNAPSHOT = "terminal_snapshot";
|
|
3676
|
+
function buildCapabilities(terminalSupported, screenRestoreSupported = false) {
|
|
3677
|
+
if (!terminalSupported) return BASE_CAPABILITIES;
|
|
3678
|
+
return screenRestoreSupported ? [...BASE_CAPABILITIES, CAPABILITY_TERMINAL, CAPABILITY_TERMINAL_SNAPSHOT] : [...BASE_CAPABILITIES, CAPABILITY_TERMINAL];
|
|
3679
|
+
}
|
|
3680
|
+
function buildDeviceAgentWsUrl(manage) {
|
|
3681
|
+
let baseUrl = (manage || DEFAULT_SERVER).trim();
|
|
3682
|
+
if (!(baseUrl.startsWith("ws://") || baseUrl.startsWith("wss://"))) {
|
|
3683
|
+
baseUrl = "wss://" + baseUrl;
|
|
3684
|
+
}
|
|
3685
|
+
return `${baseUrl.replace(/\/$/, "")}/backend/api/v1/device-agent/ws/connect`;
|
|
3686
|
+
}
|
|
3687
|
+
function sleep(millis, state) {
|
|
3688
|
+
return new Promise((resolve) => {
|
|
3689
|
+
const timer = setTimeout(() => {
|
|
3690
|
+
state.cancelPendingSleep = null;
|
|
3691
|
+
resolve();
|
|
3692
|
+
}, millis);
|
|
3693
|
+
state.cancelPendingSleep = () => {
|
|
3694
|
+
clearTimeout(timer);
|
|
3695
|
+
state.cancelPendingSleep = null;
|
|
3696
|
+
resolve();
|
|
3697
|
+
};
|
|
3698
|
+
});
|
|
3699
|
+
}
|
|
3700
|
+
function buildHello(terminalHandler) {
|
|
3701
|
+
return {
|
|
3702
|
+
agent_version: getVersion(),
|
|
3703
|
+
os: os8.platform(),
|
|
3704
|
+
hostname: os8.hostname(),
|
|
3705
|
+
capabilities: buildCapabilities(isTerminalSupported(), isScreenMirrorSupported()),
|
|
3706
|
+
terminals: terminalHandler.held()
|
|
3707
|
+
};
|
|
3708
|
+
}
|
|
3709
|
+
function startMetricsReporting(intervalSeconds, collect, send) {
|
|
3710
|
+
let stopped = false;
|
|
3711
|
+
const report = () => {
|
|
3712
|
+
collect().then((metrics) => {
|
|
3713
|
+
if (!stopped) send(metrics);
|
|
3714
|
+
}).catch((err) => logger.warn("Device metrics collection failed", { error: String(err) }));
|
|
3715
|
+
};
|
|
3716
|
+
report();
|
|
3717
|
+
const timer = setInterval(report, intervalSeconds * 1e3);
|
|
3718
|
+
return () => {
|
|
3719
|
+
stopped = true;
|
|
3720
|
+
clearInterval(timer);
|
|
3721
|
+
};
|
|
3722
|
+
}
|
|
3723
|
+
function identityForRun(token, server, stored) {
|
|
3724
|
+
if (stored?.token === token) {
|
|
3725
|
+
return { ...stored, server };
|
|
3726
|
+
}
|
|
3727
|
+
return { device_agent_id: null, token, server, enrolled_at: null };
|
|
3728
|
+
}
|
|
3729
|
+
function startDeviceAgent(token, manage, options = {}) {
|
|
3730
|
+
const server = manage || DEFAULT_SERVER;
|
|
3731
|
+
const reporter = options.reporter ?? printingReporter;
|
|
3732
|
+
const stored = readDeviceIdentity();
|
|
3733
|
+
if (stored?.device_agent_id && stored.token !== token) {
|
|
3734
|
+
reporter.replacingDevice(stored.device_agent_id);
|
|
3735
|
+
}
|
|
3736
|
+
const connection = { send: null, close: null };
|
|
3737
|
+
const context = {
|
|
3738
|
+
wsUrl: buildDeviceAgentWsUrl(manage),
|
|
3739
|
+
server,
|
|
3740
|
+
identity: identityForRun(token, server, stored),
|
|
3741
|
+
reconnectPolicy: options.reconnectPolicy ?? new ReconnectPolicy(),
|
|
3742
|
+
handshakeTimeoutMillis: options.handshakeTimeoutMillis ?? HANDSHAKE_TIMEOUT_MILLIS,
|
|
3743
|
+
terminalHandler: new TerminalHandler({
|
|
3744
|
+
send: (frame) => connection.send?.(frame),
|
|
3745
|
+
spawn: spawnPty,
|
|
3746
|
+
resolveShell: (requested) => resolveShell(requested, readShellEnvironment()),
|
|
3747
|
+
registry: new TerminalRegistry(),
|
|
3748
|
+
readShellContexts: processTableReader(),
|
|
3749
|
+
resolvedHome: resolvedHomeDirectory()
|
|
3750
|
+
}),
|
|
3751
|
+
connection,
|
|
3752
|
+
reporter,
|
|
3753
|
+
writeIdentity: options.writeIdentity ?? writeDeviceIdentity,
|
|
3754
|
+
options,
|
|
3755
|
+
state: { stopRequested: false, cancelPendingSleep: null, error: null, welcomed: false }
|
|
3756
|
+
};
|
|
3757
|
+
const done = runLoop(context);
|
|
3758
|
+
return {
|
|
3759
|
+
done,
|
|
3760
|
+
stop: () => {
|
|
3761
|
+
context.state.stopRequested = true;
|
|
3762
|
+
context.state.cancelPendingSleep?.();
|
|
3763
|
+
connection.close?.();
|
|
3764
|
+
return done;
|
|
3765
|
+
}
|
|
3766
|
+
};
|
|
3767
|
+
}
|
|
3768
|
+
async function runDeviceAgent(token, manage, options = {}) {
|
|
3769
|
+
const run2 = startDeviceAgent(token, manage, options);
|
|
3770
|
+
const sigintHandler = () => {
|
|
3771
|
+
void run2.stop();
|
|
3772
|
+
};
|
|
3773
|
+
process.once("SIGINT", sigintHandler);
|
|
3774
|
+
try {
|
|
3775
|
+
await run2.done;
|
|
3776
|
+
} finally {
|
|
3777
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
async function runLoop(context) {
|
|
3781
|
+
const { state, reporter, reconnectPolicy } = context;
|
|
3782
|
+
while (!state.stopRequested) {
|
|
3783
|
+
reporter.connecting(context.server);
|
|
3784
|
+
const outcome = await connectOnce(context);
|
|
3785
|
+
if (outcome === "terminal" || state.stopRequested) {
|
|
3786
|
+
break;
|
|
3787
|
+
}
|
|
3788
|
+
if (context.options.verifyOnly) {
|
|
3789
|
+
state.error = {
|
|
3790
|
+
code: AGENT_ERROR_NOT_CONNECTED,
|
|
3791
|
+
message: `Could not connect to ${context.server}. Check the address and the network, then try again.`
|
|
3792
|
+
};
|
|
3793
|
+
break;
|
|
3794
|
+
}
|
|
3795
|
+
const delayMillis = reconnectPolicy.nextDelayMillis();
|
|
3796
|
+
reporter.reconnecting(delayMillis, reconnectPolicy.attempt);
|
|
3797
|
+
await sleep(delayMillis, state);
|
|
3798
|
+
}
|
|
3799
|
+
context.terminalHandler.closeAll();
|
|
3800
|
+
return { error: state.error, welcomed: state.welcomed };
|
|
3801
|
+
}
|
|
3802
|
+
function fail(context, error) {
|
|
3803
|
+
context.state.error = error;
|
|
3804
|
+
context.reporter.failed(error);
|
|
3805
|
+
return "terminal";
|
|
3806
|
+
}
|
|
3807
|
+
function connectOnce(context) {
|
|
3808
|
+
const { identity, reconnectPolicy, terminalHandler, connection, reporter, state, options } = context;
|
|
3809
|
+
return new Promise((resolve) => {
|
|
3810
|
+
const ws = new WebSocket(context.wsUrl, { headers: { [TOKEN_HEADER]: identity.token } });
|
|
3811
|
+
let heartbeat = null;
|
|
3812
|
+
let stopMetrics = null;
|
|
3813
|
+
let stopTunnels = null;
|
|
3814
|
+
let pingTimer = null;
|
|
3815
|
+
let pongDeadline = null;
|
|
3816
|
+
let handshakeDeadline = null;
|
|
3817
|
+
let closeDeadline = null;
|
|
3818
|
+
let settled = false;
|
|
3819
|
+
const stopTimers = () => {
|
|
3820
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
3821
|
+
heartbeat = null;
|
|
3822
|
+
stopMetrics?.();
|
|
3823
|
+
stopMetrics = null;
|
|
3824
|
+
stopTunnels?.();
|
|
3825
|
+
stopTunnels = null;
|
|
3826
|
+
if (pingTimer) clearInterval(pingTimer);
|
|
3827
|
+
pingTimer = null;
|
|
3828
|
+
if (pongDeadline) clearTimeout(pongDeadline);
|
|
3829
|
+
pongDeadline = null;
|
|
3830
|
+
if (handshakeDeadline) clearTimeout(handshakeDeadline);
|
|
3831
|
+
handshakeDeadline = null;
|
|
3832
|
+
};
|
|
3833
|
+
const finish = (outcome) => {
|
|
3834
|
+
if (settled) return;
|
|
3835
|
+
settled = true;
|
|
3836
|
+
stopTimers();
|
|
3837
|
+
if (closeDeadline) clearTimeout(closeDeadline);
|
|
3838
|
+
closeDeadline = null;
|
|
3839
|
+
if (reconnectPolicy.markDisconnected()) {
|
|
3840
|
+
logger.info("Connection held long enough to reset the reconnect schedule");
|
|
3841
|
+
}
|
|
3842
|
+
connection.send = null;
|
|
3843
|
+
connection.close = null;
|
|
3844
|
+
terminalHandler.suspend();
|
|
3845
|
+
resolve(outcome);
|
|
3846
|
+
};
|
|
3847
|
+
const sendFrame = (frame) => {
|
|
3848
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
3849
|
+
ws.send(JSON.stringify(frame));
|
|
3850
|
+
}
|
|
3851
|
+
};
|
|
3852
|
+
const dropDeadSocket = (message) => {
|
|
3853
|
+
logger.warn(message);
|
|
3854
|
+
reporter.warn(message);
|
|
3855
|
+
ws.terminate();
|
|
3856
|
+
};
|
|
3857
|
+
connection.close = () => {
|
|
3858
|
+
if (ws.readyState === WebSocket.CONNECTING) {
|
|
3859
|
+
ws.terminate();
|
|
3860
|
+
return;
|
|
3861
|
+
}
|
|
3862
|
+
ws.close(CLOSE_CODE_NORMAL);
|
|
3863
|
+
closeDeadline = setTimeout(() => ws.terminate(), CLOSE_TIMEOUT_MILLIS);
|
|
3864
|
+
};
|
|
3865
|
+
ws.once("open", () => {
|
|
3866
|
+
logger.info("Device agent socket open, sending hello");
|
|
3867
|
+
connection.send = sendFrame;
|
|
3868
|
+
sendFrame(request(CHANNEL_SYSTEM, OP_HELLO, buildHello(terminalHandler)));
|
|
3869
|
+
handshakeDeadline = setTimeout(() => {
|
|
3870
|
+
dropDeadSocket("No welcome from the dashboard. Reconnecting.");
|
|
3871
|
+
}, context.handshakeTimeoutMillis);
|
|
3872
|
+
});
|
|
3873
|
+
const startPongWatchdog = (intervalSeconds) => {
|
|
3874
|
+
const intervalMillis = intervalSeconds * 1e3;
|
|
3875
|
+
const graceMillis = intervalMillis * PONG_GRACE_INTERVALS;
|
|
3876
|
+
const armDeadline = () => {
|
|
3877
|
+
if (pongDeadline) clearTimeout(pongDeadline);
|
|
3878
|
+
pongDeadline = setTimeout(() => {
|
|
3879
|
+
dropDeadSocket("Connection stopped answering. Reconnecting.");
|
|
3880
|
+
}, graceMillis);
|
|
3881
|
+
};
|
|
3882
|
+
ws.on("pong", armDeadline);
|
|
3883
|
+
pingTimer = setInterval(() => {
|
|
3884
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
3885
|
+
ws.ping();
|
|
3886
|
+
}
|
|
3887
|
+
}, intervalMillis);
|
|
3888
|
+
armDeadline();
|
|
3889
|
+
};
|
|
3890
|
+
const startHeartbeat = (intervalSeconds) => {
|
|
3891
|
+
heartbeat = setInterval(
|
|
3892
|
+
() => sendFrame(
|
|
3893
|
+
event(CHANNEL_SYSTEM, OP_HEARTBEAT, { uptime_seconds: Math.floor(os8.uptime()) })
|
|
3894
|
+
),
|
|
3895
|
+
intervalSeconds * 1e3
|
|
3896
|
+
);
|
|
3897
|
+
};
|
|
3898
|
+
const onWelcome = (welcome) => {
|
|
3899
|
+
stopTimers();
|
|
3900
|
+
if (options.verifyOnly) {
|
|
3901
|
+
state.stopRequested = true;
|
|
3902
|
+
connection.close?.();
|
|
3903
|
+
return;
|
|
3904
|
+
}
|
|
3905
|
+
reconnectPolicy.markConnected();
|
|
3906
|
+
terminalHandler.configure(
|
|
3907
|
+
welcome.terminal_enabled,
|
|
3908
|
+
welcome.max_terminals_per_device,
|
|
3909
|
+
welcome.terminal_window_bytes,
|
|
3910
|
+
welcome.max_frame_bytes,
|
|
3911
|
+
welcome.terminal_idle_timeout_seconds,
|
|
3912
|
+
welcome.terminal_max_session_seconds
|
|
3913
|
+
);
|
|
3914
|
+
terminalHandler.resumeAll();
|
|
3915
|
+
startHeartbeat(welcome.heartbeat_interval_seconds);
|
|
3916
|
+
startPongWatchdog(welcome.heartbeat_interval_seconds);
|
|
3917
|
+
sendFrame(event(CHANNEL_DEVICE, OP_INFO, collectSystemInfo()));
|
|
3918
|
+
stopMetrics = startMetricsReporting(
|
|
3919
|
+
welcome.stats_interval_seconds,
|
|
3920
|
+
collectMetrics,
|
|
3921
|
+
(metrics) => sendFrame(event(CHANNEL_DEVICE, OP_METRICS, metrics))
|
|
3922
|
+
);
|
|
3923
|
+
stopTunnels = startTunnelReporting(
|
|
3924
|
+
() => collectTunnelList(options.tunnelListSource),
|
|
3925
|
+
(tunnels) => sendFrame(event(CHANNEL_DEVICE, OP_TUNNELS, tunnels))
|
|
3926
|
+
);
|
|
3927
|
+
};
|
|
3928
|
+
ws.on("message", (data) => {
|
|
3929
|
+
const envelope = parseEnvelope(data.toString("utf8"));
|
|
3930
|
+
if (!envelope) {
|
|
3931
|
+
logger.debug("Ignoring unparseable frame");
|
|
3932
|
+
return;
|
|
3933
|
+
}
|
|
3934
|
+
if (envelope.ch === CHANNEL_TERMINAL) {
|
|
3935
|
+
terminalHandler.handle(envelope);
|
|
3936
|
+
return;
|
|
3937
|
+
}
|
|
3938
|
+
if (envelope.ch === CHANNEL_TUNNEL) {
|
|
3939
|
+
void handleTunnelAction(envelope, (frame) => connection.send?.(frame), options.tunnelActions).then(() => stopTunnels?.pollNow());
|
|
3940
|
+
return;
|
|
3941
|
+
}
|
|
3942
|
+
const outcome = handleFrame(envelope, context, onWelcome);
|
|
3943
|
+
if (outcome === "terminal") {
|
|
3944
|
+
finish("terminal");
|
|
3945
|
+
ws.close();
|
|
3946
|
+
}
|
|
3947
|
+
});
|
|
3948
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
3949
|
+
if (res.statusCode === 401) {
|
|
3950
|
+
finish(fail(context, {
|
|
3951
|
+
code: AGENT_ERROR_UNAUTHORIZED,
|
|
3952
|
+
message: "Unauthorized. This device token is not valid. Re-enrol this machine with: pinggy device login"
|
|
3953
|
+
}));
|
|
3954
|
+
} else {
|
|
3955
|
+
reporter.warn(`Unexpected HTTP ${res.statusCode}.`);
|
|
3956
|
+
finish("retry");
|
|
3957
|
+
}
|
|
3958
|
+
ws.close();
|
|
3959
|
+
});
|
|
3960
|
+
ws.on("close", (code, reason) => {
|
|
3961
|
+
logger.info("Device agent socket closed", { code, reason: reason.toString() });
|
|
3962
|
+
finish(code === CLOSE_CODE_REVOKED ? "terminal" : "retry");
|
|
3963
|
+
});
|
|
3964
|
+
ws.on("error", (err) => {
|
|
3965
|
+
logger.warn("Device agent socket error", { error: err.message });
|
|
3966
|
+
if (!state.stopRequested) {
|
|
3967
|
+
reporter.warn(err.message);
|
|
3968
|
+
}
|
|
3969
|
+
finish("retry");
|
|
3970
|
+
});
|
|
3971
|
+
});
|
|
3972
|
+
}
|
|
3973
|
+
function handleFrame(envelope, context, onWelcome) {
|
|
3974
|
+
const { identity, reporter } = context;
|
|
3975
|
+
if (envelope.ch !== CHANNEL_SYSTEM) {
|
|
3976
|
+
logger.debug("Ignoring frame on unknown channel", { channel: envelope.ch });
|
|
3977
|
+
return "retry";
|
|
3978
|
+
}
|
|
3979
|
+
if (envelope.op === OP_WELCOME) {
|
|
3980
|
+
const error = ErrorPayloadSchema.safeParse(envelope.payload);
|
|
3981
|
+
if (error.success) {
|
|
3982
|
+
if (error.data.error.code === ERROR_ALREADY_CONNECTED) {
|
|
3983
|
+
reporter.warn("This device is connected elsewhere. Retrying.");
|
|
3984
|
+
return "retry";
|
|
3985
|
+
}
|
|
3986
|
+
return fail(context, {
|
|
3987
|
+
code: AGENT_ERROR_HANDSHAKE_REFUSED,
|
|
3988
|
+
message: `Handshake refused: ${error.data.error.message}`
|
|
3989
|
+
});
|
|
3990
|
+
}
|
|
3991
|
+
const welcome = WelcomeSchema.safeParse(envelope.payload);
|
|
3992
|
+
if (!welcome.success) {
|
|
3993
|
+
logger.warn("Welcome payload could not be read");
|
|
3994
|
+
return "retry";
|
|
3995
|
+
}
|
|
3996
|
+
identity.device_agent_id = welcome.data.device_agent_id;
|
|
3997
|
+
identity.enrolled_at = identity.enrolled_at ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
3998
|
+
context.writeIdentity(identity);
|
|
3999
|
+
context.state.welcomed = true;
|
|
4000
|
+
reporter.connected(welcome.data);
|
|
4001
|
+
onWelcome(welcome.data);
|
|
4002
|
+
return "retry";
|
|
4003
|
+
}
|
|
4004
|
+
if (envelope.op === OP_DISCONNECT) {
|
|
4005
|
+
const disconnect = DisconnectSchema.safeParse(envelope.payload);
|
|
4006
|
+
const reason = disconnect.success ? disconnect.data.reason : "unknown";
|
|
4007
|
+
const { outcome, message } = describeDisconnect(reason);
|
|
4008
|
+
if (outcome === "retry") {
|
|
4009
|
+
reporter.warn(message);
|
|
4010
|
+
return "retry";
|
|
4011
|
+
}
|
|
4012
|
+
return fail(context, { code: disconnectErrorCode(reason), message });
|
|
4013
|
+
}
|
|
4014
|
+
logger.debug("Ignoring unhandled system op", { op: envelope.op });
|
|
4015
|
+
return "retry";
|
|
4016
|
+
}
|
|
4017
|
+
var DISCONNECT_REASON_SHUTDOWN = "shutdown";
|
|
4018
|
+
var DISCONNECT_MESSAGES = {
|
|
4019
|
+
revoked: "Credential revoked. Copy the new install command from the dashboard and run it to reconnect.",
|
|
4020
|
+
deleted: "Device deleted from the dashboard. Add it again to reconnect this machine.",
|
|
4021
|
+
replaced: "This device connected again from another session. Stopping this one."
|
|
4022
|
+
};
|
|
4023
|
+
function disconnectErrorCode(reason) {
|
|
4024
|
+
return reason in DISCONNECT_MESSAGES ? reason : AGENT_ERROR_DISCONNECTED;
|
|
4025
|
+
}
|
|
4026
|
+
function describeDisconnect(reason) {
|
|
4027
|
+
if (reason === DISCONNECT_REASON_SHUTDOWN) {
|
|
4028
|
+
return { outcome: "retry", message: "Server is shutting down. Reconnecting." };
|
|
4029
|
+
}
|
|
4030
|
+
return {
|
|
4031
|
+
outcome: "terminal",
|
|
4032
|
+
message: DISCONNECT_MESSAGES[reason] ?? `Disconnected by the dashboard: ${reason}. Re-enrol this device to continue.`
|
|
4033
|
+
};
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
// src/devices/login/deviceLogin.ts
|
|
4037
|
+
import os9 from "os";
|
|
4038
|
+
|
|
4039
|
+
// src/devices/login/login_schema.ts
|
|
4040
|
+
import { z as z3 } from "zod";
|
|
4041
|
+
var LoginStartedSchema = z3.object({
|
|
4042
|
+
device_code: z3.string().min(1),
|
|
4043
|
+
match_code: z3.string().min(1).optional(),
|
|
4044
|
+
expires_in_seconds: z3.number().int().positive(),
|
|
4045
|
+
poll_interval_seconds: z3.number().int().positive()
|
|
4046
|
+
});
|
|
4047
|
+
var LoginStatus = {
|
|
4048
|
+
Pending: "pending",
|
|
4049
|
+
MfaRequired: "mfa_required",
|
|
4050
|
+
Approved: "approved"
|
|
4051
|
+
};
|
|
4052
|
+
var LoginAnswerSchema = z3.object({
|
|
4053
|
+
status: z3.enum([LoginStatus.Pending, LoginStatus.MfaRequired, LoginStatus.Approved]),
|
|
4054
|
+
token: z3.string().min(1).optional(),
|
|
4055
|
+
device_agent_id: z3.string().min(1).optional(),
|
|
4056
|
+
device_name: z3.string().optional(),
|
|
4057
|
+
open_url: z3.string().optional(),
|
|
4058
|
+
device_url: z3.string().optional()
|
|
4059
|
+
});
|
|
4060
|
+
var LoginErrorSchema = z3.object({
|
|
4061
|
+
error_type: z3.string(),
|
|
4062
|
+
errors: z3.array(z3.string()).default([])
|
|
4063
|
+
});
|
|
4064
|
+
var LoginErrorType = {
|
|
4065
|
+
/** A wrong code or authenticator code, with attempts left. Asked again. */
|
|
4066
|
+
InvalidCode: "device_login_invalid_code",
|
|
4067
|
+
/** Expired, ended after too many wrong codes, or already used. */
|
|
4068
|
+
Expired: "device_login_expired",
|
|
4069
|
+
RateLimited: "device_login_rate_limited",
|
|
4070
|
+
InvalidClient: "device_login_invalid_client"
|
|
4071
|
+
};
|
|
4072
|
+
|
|
4073
|
+
// src/devices/login/loginApi.ts
|
|
4074
|
+
var DEVICE_LOGIN_CLIENT_TOKEN = "pinggy-cli-1";
|
|
4075
|
+
var DEFAULT_DASHBOARD = "dashboard.pinggy.io";
|
|
4076
|
+
var LOGIN_PATH = "/backend/api/v1/device-agent/login";
|
|
4077
|
+
var SCHEME_PATTERN = /^(wss?|https?):\/\//i;
|
|
4078
|
+
var PLAIN_SCHEMES = /* @__PURE__ */ new Set(["ws", "http"]);
|
|
4079
|
+
function buildDashboardHttpUrl(manage) {
|
|
4080
|
+
const address = (manage || DEFAULT_DASHBOARD).trim().replace(/\/+$/, "");
|
|
4081
|
+
const match = SCHEME_PATTERN.exec(address);
|
|
4082
|
+
const host = match ? address.slice(match[0].length) : address;
|
|
4083
|
+
const scheme = match && PLAIN_SCHEMES.has(match[1].toLowerCase()) ? "http" : "https";
|
|
4084
|
+
return `${scheme}://${host}${LOGIN_PATH}`;
|
|
4085
|
+
}
|
|
4086
|
+
var DeviceLoginApiError = class extends Error {
|
|
4087
|
+
constructor(status, errorType, message) {
|
|
4088
|
+
super(message);
|
|
4089
|
+
this.status = status;
|
|
4090
|
+
this.errorType = errorType;
|
|
4091
|
+
this.name = "DeviceLoginApiError";
|
|
4092
|
+
}
|
|
4093
|
+
};
|
|
4094
|
+
var UNREADABLE_ANSWER = "invalid_answer";
|
|
4095
|
+
function createDeviceLoginApi(manage, fetchImpl = fetch) {
|
|
4096
|
+
const baseUrl = buildDashboardHttpUrl(manage);
|
|
4097
|
+
async function post(path6, body, schema) {
|
|
4098
|
+
const response2 = await fetchImpl(`${baseUrl}/${path6}`, {
|
|
4099
|
+
method: "POST",
|
|
4100
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
4101
|
+
body: JSON.stringify(body)
|
|
4102
|
+
});
|
|
4103
|
+
const json = await readJson(response2);
|
|
4104
|
+
if (!response2.ok) {
|
|
4105
|
+
const refusal = LoginErrorSchema.safeParse(json);
|
|
4106
|
+
if (refusal.success && refusal.data.errors.length > 0) {
|
|
4107
|
+
throw new DeviceLoginApiError(response2.status, refusal.data.error_type, refusal.data.errors[0]);
|
|
4108
|
+
}
|
|
4109
|
+
throw new DeviceLoginApiError(
|
|
4110
|
+
response2.status,
|
|
4111
|
+
`http_${response2.status}`,
|
|
4112
|
+
`The dashboard answered HTTP ${response2.status}.`
|
|
4113
|
+
);
|
|
4114
|
+
}
|
|
4115
|
+
const answer = schema.safeParse(json);
|
|
4116
|
+
if (!answer.success) {
|
|
4117
|
+
throw new DeviceLoginApiError(
|
|
4118
|
+
response2.status,
|
|
4119
|
+
UNREADABLE_ANSWER,
|
|
4120
|
+
"The dashboard sent an answer this pinggy cannot read. Update pinggy and try again."
|
|
4121
|
+
);
|
|
4122
|
+
}
|
|
4123
|
+
return answer.data;
|
|
4124
|
+
}
|
|
4125
|
+
return {
|
|
4126
|
+
start: (request2) => post("start", request2, LoginStartedSchema),
|
|
4127
|
+
verifyCode: (deviceCode, code) => post("verify-code", { device_code: deviceCode, code }, LoginAnswerSchema),
|
|
4128
|
+
verifyMfa: (deviceCode, totp) => post("verify-mfa", { device_code: deviceCode, totp }, LoginAnswerSchema),
|
|
4129
|
+
poll: (deviceCode) => post("poll", { device_code: deviceCode }, LoginAnswerSchema)
|
|
4130
|
+
};
|
|
4131
|
+
}
|
|
4132
|
+
async function readJson(response2) {
|
|
4133
|
+
const text = await response2.text();
|
|
4134
|
+
if (!text) {
|
|
4135
|
+
return void 0;
|
|
4136
|
+
}
|
|
4137
|
+
try {
|
|
4138
|
+
return JSON.parse(text);
|
|
4139
|
+
} catch {
|
|
4140
|
+
return void 0;
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
// src/devices/login/prompts.ts
|
|
4145
|
+
import { createInterface } from "readline/promises";
|
|
4146
|
+
import { Writable } from "stream";
|
|
4147
|
+
function createTerminalPrompter(input, output) {
|
|
4148
|
+
return {
|
|
4149
|
+
async ask(question, signal) {
|
|
4150
|
+
const readline = createInterface({ input, output, terminal: true });
|
|
4151
|
+
try {
|
|
4152
|
+
return await readline.question(question, signal ? { signal } : {});
|
|
4153
|
+
} finally {
|
|
4154
|
+
readline.close();
|
|
4155
|
+
}
|
|
4156
|
+
},
|
|
4157
|
+
async askHidden(question, signal) {
|
|
4158
|
+
output.write(question);
|
|
4159
|
+
const readline = createInterface({ input, output: discardingOutput(), terminal: true });
|
|
4160
|
+
try {
|
|
4161
|
+
const answer = await readline.question("", signal ? { signal } : {});
|
|
4162
|
+
output.write("\n");
|
|
4163
|
+
return answer;
|
|
4164
|
+
} finally {
|
|
4165
|
+
readline.close();
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
};
|
|
4169
|
+
}
|
|
4170
|
+
var terminalPrompter = createTerminalPrompter(process.stdin, process.stdout);
|
|
4171
|
+
function isInteractiveTerminal() {
|
|
4172
|
+
return process.stdin.isTTY === true;
|
|
4173
|
+
}
|
|
4174
|
+
function discardingOutput() {
|
|
4175
|
+
return new Writable({
|
|
4176
|
+
write(_chunk, _encoding, callback) {
|
|
4177
|
+
callback();
|
|
4178
|
+
}
|
|
4179
|
+
});
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
// src/devices/login/deviceLogin.ts
|
|
4183
|
+
var EXIT_OK = 0;
|
|
4184
|
+
var EXIT_FAILED = 1;
|
|
4185
|
+
var DEFAULT_SERVER2 = "dashboard.pinggy.io";
|
|
4186
|
+
var YES_ANSWER = /^y(es)?$/i;
|
|
4187
|
+
var SECONDS_PER_MINUTE = 60;
|
|
4188
|
+
var MILLIS_PER_SECOND2 = 1e3;
|
|
4189
|
+
var FIRST_SERVER_ERROR_STATUS = 500;
|
|
4190
|
+
var NO_TERMINAL_MESSAGE = "pinggy device login needs a terminal to type the email and the code. On a machine without one, add the device in the dashboard and run: pinggy device connect --token <TOKEN>";
|
|
4191
|
+
function machineDeviceLoginDependencies() {
|
|
4192
|
+
return {
|
|
4193
|
+
createApi: (manage) => createDeviceLoginApi(manage),
|
|
4194
|
+
prompter: terminalPrompter,
|
|
4195
|
+
isInteractive: isInteractiveTerminal,
|
|
4196
|
+
readIdentity: readDeviceIdentity,
|
|
4197
|
+
writeIdentity: writeDeviceIdentity,
|
|
4198
|
+
runAgent: (token, manage) => runDeviceAgent(token, manage),
|
|
4199
|
+
renderQr: async (url) => (await createQrCodes([url]))[0],
|
|
4200
|
+
machine: () => ({
|
|
4201
|
+
hostname: os9.hostname(),
|
|
4202
|
+
os: os9.platform(),
|
|
4203
|
+
arch: os9.arch(),
|
|
4204
|
+
agent_version: getVersion()
|
|
4205
|
+
}),
|
|
4206
|
+
print: (message) => printer_default.print(message),
|
|
4207
|
+
printError: (message) => printer_default.error(message),
|
|
4208
|
+
sleep: sleepUnlessAborted
|
|
4209
|
+
};
|
|
4210
|
+
}
|
|
4211
|
+
async function runDeviceLogin(options, dependencies = machineDeviceLoginDependencies()) {
|
|
4212
|
+
if (!dependencies.isInteractive()) {
|
|
4213
|
+
dependencies.printError(NO_TERMINAL_MESSAGE);
|
|
4214
|
+
return EXIT_FAILED;
|
|
4215
|
+
}
|
|
4216
|
+
const stored = dependencies.readIdentity();
|
|
4217
|
+
const manage = options.manage ?? stored?.server;
|
|
4218
|
+
if (stored) {
|
|
4219
|
+
const answer = await dependencies.prompter.ask(
|
|
4220
|
+
`This machine is already enrolled as ${stored.device_agent_id ?? "a device"} on ${stored.server}. Add it again as a new device? [y/N] `
|
|
4221
|
+
);
|
|
4222
|
+
if (!YES_ANSWER.test(answer.trim())) {
|
|
4223
|
+
return await dependencies.runAgent(stored.token, manage) ?? EXIT_OK;
|
|
4224
|
+
}
|
|
4225
|
+
}
|
|
4226
|
+
const email = (await dependencies.prompter.ask("Email: ")).trim();
|
|
4227
|
+
if (!email) {
|
|
4228
|
+
dependencies.printError("An email is required.");
|
|
4229
|
+
return EXIT_FAILED;
|
|
4230
|
+
}
|
|
4231
|
+
const api = dependencies.createApi(manage);
|
|
4232
|
+
let approved;
|
|
4233
|
+
let linkMinutes;
|
|
4234
|
+
try {
|
|
4235
|
+
const started = await api.start({
|
|
4236
|
+
email,
|
|
4237
|
+
client_token: DEVICE_LOGIN_CLIENT_TOKEN,
|
|
4238
|
+
...dependencies.machine(),
|
|
4239
|
+
...options.name ? { name: options.name } : {}
|
|
4240
|
+
});
|
|
4241
|
+
linkMinutes = Math.round(started.expires_in_seconds / SECONDS_PER_MINUTE);
|
|
4242
|
+
dependencies.print(`We sent a 6-digit code to ${email}. It works for ${linkMinutes} minutes.`);
|
|
4243
|
+
if (started.match_code) {
|
|
4244
|
+
dependencies.print("Type it below, or click Approve sign-in in the email.");
|
|
4245
|
+
dependencies.print(`If you use the email: approve only if the page shows ${started.match_code}. A different code is someone else's sign-in.`);
|
|
4246
|
+
}
|
|
4247
|
+
approved = await waitForApproval(api, started.device_code, started.poll_interval_seconds, dependencies);
|
|
4248
|
+
} catch (err) {
|
|
4249
|
+
dependencies.printError(messageOf(err, manage));
|
|
4250
|
+
return EXIT_FAILED;
|
|
4251
|
+
}
|
|
4252
|
+
if (!approved.token || !approved.device_agent_id) {
|
|
4253
|
+
dependencies.printError("The dashboard approved the sign-in without a device token. Update pinggy and try again.");
|
|
4254
|
+
return EXIT_FAILED;
|
|
4255
|
+
}
|
|
4256
|
+
dependencies.writeIdentity({
|
|
4257
|
+
device_agent_id: approved.device_agent_id,
|
|
4258
|
+
token: approved.token,
|
|
4259
|
+
server: manage || DEFAULT_SERVER2,
|
|
4260
|
+
enrolled_at: null
|
|
4261
|
+
});
|
|
4262
|
+
dependencies.print(`Signed in as ${email}. Added device ${approved.device_name ?? approved.device_agent_id}.`);
|
|
4263
|
+
await printDeviceLink(approved, linkMinutes, dependencies);
|
|
4264
|
+
return await dependencies.runAgent(approved.token, manage) ?? EXIT_OK;
|
|
4265
|
+
}
|
|
4266
|
+
async function waitForApproval(api, deviceCode, pollIntervalSeconds, dependencies) {
|
|
4267
|
+
const controller = new AbortController();
|
|
4268
|
+
const prompt = { showing: false };
|
|
4269
|
+
const typed = typeTheCode(api, deviceCode, controller.signal, prompt, dependencies);
|
|
4270
|
+
const polled = pollUntilApproved(
|
|
4271
|
+
api,
|
|
4272
|
+
deviceCode,
|
|
4273
|
+
pollIntervalSeconds * MILLIS_PER_SECOND2,
|
|
4274
|
+
controller.signal,
|
|
4275
|
+
dependencies
|
|
4276
|
+
);
|
|
4277
|
+
typed.catch(() => void 0);
|
|
4278
|
+
polled.catch(() => void 0);
|
|
4279
|
+
try {
|
|
4280
|
+
return await Promise.race([typed, polled]);
|
|
4281
|
+
} finally {
|
|
4282
|
+
controller.abort();
|
|
4283
|
+
if (prompt.showing) {
|
|
4284
|
+
dependencies.print("");
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
}
|
|
4288
|
+
async function typeTheCode(api, deviceCode, signal, prompt, dependencies) {
|
|
4289
|
+
const ask = async (question, hidden = false) => {
|
|
4290
|
+
prompt.showing = true;
|
|
4291
|
+
try {
|
|
4292
|
+
const answer2 = hidden ? dependencies.prompter.askHidden(question, signal) : dependencies.prompter.ask(question, signal);
|
|
4293
|
+
return (await answer2).trim();
|
|
4294
|
+
} finally {
|
|
4295
|
+
prompt.showing = false;
|
|
4296
|
+
}
|
|
4297
|
+
};
|
|
4298
|
+
let answer = null;
|
|
4299
|
+
while (answer === null) {
|
|
4300
|
+
const code = await ask("Code from the email: ");
|
|
4301
|
+
if (code) {
|
|
4302
|
+
answer = await askAgainOnWrongAnswer(() => api.verifyCode(deviceCode, code), dependencies);
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
while (answer.status === LoginStatus.MfaRequired) {
|
|
4306
|
+
const totp = await ask("Authenticator code: ", true);
|
|
4307
|
+
if (totp) {
|
|
4308
|
+
answer = await askAgainOnWrongAnswer(() => api.verifyMfa(deviceCode, totp), dependencies) ?? answer;
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
if (answer.status === LoginStatus.Pending) {
|
|
4312
|
+
dependencies.print("Approved from the email link. Waiting for the dashboard to finish.");
|
|
4313
|
+
return waitUntilAborted(signal);
|
|
4314
|
+
}
|
|
4315
|
+
if (answer.status !== LoginStatus.Approved) {
|
|
4316
|
+
throw new DeviceLoginApiError(
|
|
4317
|
+
0,
|
|
4318
|
+
"invalid_answer",
|
|
4319
|
+
"The dashboard answered the code with something this pinggy cannot use. Update pinggy and try again."
|
|
4320
|
+
);
|
|
4321
|
+
}
|
|
4322
|
+
return answer;
|
|
4323
|
+
}
|
|
4324
|
+
async function askAgainOnWrongAnswer(verify, dependencies) {
|
|
4325
|
+
try {
|
|
4326
|
+
return await verify();
|
|
4327
|
+
} catch (err) {
|
|
4328
|
+
if (err instanceof DeviceLoginApiError && err.errorType === LoginErrorType.InvalidCode) {
|
|
4329
|
+
dependencies.printError(err.message);
|
|
4330
|
+
return null;
|
|
4331
|
+
}
|
|
4332
|
+
throw err;
|
|
4333
|
+
}
|
|
4334
|
+
}
|
|
4335
|
+
async function pollUntilApproved(api, deviceCode, intervalMillis, signal, dependencies) {
|
|
4336
|
+
while (!signal.aborted) {
|
|
4337
|
+
await dependencies.sleep(intervalMillis, signal);
|
|
4338
|
+
if (signal.aborted) {
|
|
4339
|
+
break;
|
|
4340
|
+
}
|
|
4341
|
+
try {
|
|
4342
|
+
const answer = await api.poll(deviceCode);
|
|
4343
|
+
if (answer.status === LoginStatus.Approved) {
|
|
4344
|
+
return answer;
|
|
4345
|
+
}
|
|
4346
|
+
} catch (err) {
|
|
4347
|
+
if (err instanceof DeviceLoginApiError && err.status > 0 && err.status < FIRST_SERVER_ERROR_STATUS) {
|
|
4348
|
+
throw err;
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
4352
|
+
throw signal.reason ?? new Error("The poll was stopped.");
|
|
4353
|
+
}
|
|
4354
|
+
async function printDeviceLink(approved, linkMinutes, dependencies) {
|
|
4355
|
+
let link;
|
|
4356
|
+
if (approved.open_url) {
|
|
4357
|
+
link = approved.open_url;
|
|
4358
|
+
dependencies.print(`Open the device in a browser. This link signs you in once, within ${linkMinutes} minutes:`);
|
|
4359
|
+
} else if (approved.device_url) {
|
|
4360
|
+
link = approved.device_url;
|
|
4361
|
+
dependencies.print("Open the device in a browser:");
|
|
4362
|
+
} else {
|
|
4363
|
+
return;
|
|
4364
|
+
}
|
|
4365
|
+
dependencies.print(` ${link}`);
|
|
4366
|
+
try {
|
|
4367
|
+
dependencies.print(await dependencies.renderQr(link));
|
|
4368
|
+
} catch {
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
function messageOf(err, manage) {
|
|
4372
|
+
if (err instanceof DeviceLoginApiError) {
|
|
4373
|
+
return err.message;
|
|
4374
|
+
}
|
|
4375
|
+
const cause = err?.cause;
|
|
4376
|
+
const causeText = cause instanceof Error ? `: ${cause.message}` : "";
|
|
4377
|
+
const detail = err instanceof Error ? `${err.message}${causeText}` : String(err);
|
|
4378
|
+
return `Could not reach the dashboard at ${buildDashboardHttpUrl(manage)} (${detail}).`;
|
|
4379
|
+
}
|
|
4380
|
+
function waitUntilAborted(signal) {
|
|
4381
|
+
return new Promise((_resolve, reject) => {
|
|
4382
|
+
const stop = () => reject(signal.reason ?? new Error("The wait was stopped."));
|
|
4383
|
+
if (signal.aborted) {
|
|
4384
|
+
stop();
|
|
4385
|
+
return;
|
|
4386
|
+
}
|
|
4387
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
4388
|
+
});
|
|
4389
|
+
}
|
|
4390
|
+
function sleepUnlessAborted(millis, signal) {
|
|
4391
|
+
return new Promise((resolve) => {
|
|
4392
|
+
if (signal.aborted) {
|
|
4393
|
+
resolve();
|
|
4394
|
+
return;
|
|
4395
|
+
}
|
|
4396
|
+
const onAbort = () => {
|
|
4397
|
+
clearTimeout(timer);
|
|
4398
|
+
resolve();
|
|
4399
|
+
};
|
|
4400
|
+
const timer = setTimeout(() => {
|
|
4401
|
+
signal.removeEventListener("abort", onAbort);
|
|
4402
|
+
resolve();
|
|
4403
|
+
}, millis);
|
|
4404
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
4405
|
+
});
|
|
4406
|
+
}
|
|
4407
|
+
|
|
4408
|
+
// src/devices/backgroundAgent.ts
|
|
4409
|
+
var POLL_INTERVAL_MILLIS = 500;
|
|
4410
|
+
var CONNECT_WAIT_MILLIS = 15e3;
|
|
4411
|
+
var verifyingReporter = {
|
|
4412
|
+
connecting: () => void 0,
|
|
4413
|
+
connected: () => void 0,
|
|
4414
|
+
reconnecting: () => void 0,
|
|
4415
|
+
warn: (message) => printer_default.warn(message),
|
|
4416
|
+
// The caller prints the error the run ends with.
|
|
4417
|
+
failed: () => void 0,
|
|
4418
|
+
replacingDevice: printingReporter.replacingDevice
|
|
4419
|
+
};
|
|
4420
|
+
function machineBackgroundAgentDependencies() {
|
|
4421
|
+
return {
|
|
4422
|
+
readIdentity: readDeviceIdentity,
|
|
4423
|
+
findDaemon: getDaemonInfo,
|
|
4424
|
+
ensureDaemon: ensureDaemonRunning,
|
|
4425
|
+
client: (port) => new IPCClient(port, "cli"),
|
|
4426
|
+
verify: (token, manage) => startDeviceAgent(token, manage, {
|
|
4427
|
+
verifyOnly: true,
|
|
4428
|
+
reporter: verifyingReporter
|
|
4429
|
+
}).done,
|
|
4430
|
+
sleep: (millis) => new Promise((resolve) => setTimeout(resolve, millis)),
|
|
4431
|
+
now: () => Date.now(),
|
|
4432
|
+
print: (message) => printer_default.print(message),
|
|
4433
|
+
printSuccess: (message) => printer_default.success(message),
|
|
4434
|
+
printWarning: (message) => printer_default.warn(message),
|
|
4435
|
+
printError: (message) => printer_default.error(message),
|
|
4436
|
+
daemonLogPath: getDaemonLogPath
|
|
4437
|
+
};
|
|
4438
|
+
}
|
|
4439
|
+
function isAgentRunning(status) {
|
|
4440
|
+
return status.state !== DeviceAgentState.Stopped;
|
|
4441
|
+
}
|
|
4442
|
+
function differsFromStored(target, stored) {
|
|
4443
|
+
if (!stored?.token) return true;
|
|
4444
|
+
return stored.token !== target.token || stored.server !== (target.manage || DEFAULT_SERVER);
|
|
4445
|
+
}
|
|
4446
|
+
function olderDaemonMessage(daemon) {
|
|
4447
|
+
if (daemon.host === DaemonHost.APP) {
|
|
4448
|
+
return "The Pinggy app runs an older daemon. Update the Pinggy app, or quit it and run this again.";
|
|
4449
|
+
}
|
|
4450
|
+
return "The running daemon predates background devices. Run pinggy daemon stop, then retry. This stops all running tunnels.";
|
|
4451
|
+
}
|
|
4452
|
+
async function readBackgroundAgent(dependencies) {
|
|
4453
|
+
const daemon = dependencies.findDaemon();
|
|
4454
|
+
if (!daemon || !isIpcCompatible(daemon)) {
|
|
4455
|
+
return null;
|
|
4456
|
+
}
|
|
4457
|
+
try {
|
|
4458
|
+
return { daemon, status: await dependencies.client(daemon.port).getDeviceAgentStatus() };
|
|
4459
|
+
} catch (err) {
|
|
4460
|
+
logger.debug("No background device agent to read", { error: errorMessage(err) });
|
|
4461
|
+
return null;
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
4464
|
+
async function startAgentInBackground(target, dependencies) {
|
|
4465
|
+
if (differsFromStored(target, dependencies.readIdentity())) {
|
|
4466
|
+
const verified = await dependencies.verify(target.token, target.manage);
|
|
4467
|
+
if (verified.error || !verified.welcomed) {
|
|
4468
|
+
dependencies.printError(verified.error?.message ?? "The device did not connect. Nothing changed.");
|
|
4469
|
+
return EXIT_FAILED;
|
|
4470
|
+
}
|
|
4471
|
+
}
|
|
4472
|
+
dependencies.print("Starting the device agent in the background.");
|
|
4473
|
+
let daemon;
|
|
4474
|
+
try {
|
|
4475
|
+
daemon = await dependencies.ensureDaemon();
|
|
4476
|
+
} catch (err) {
|
|
4477
|
+
dependencies.printError(errorMessage(err));
|
|
4478
|
+
return EXIT_FAILED;
|
|
4479
|
+
}
|
|
4480
|
+
const client = dependencies.client(daemon.port);
|
|
4481
|
+
try {
|
|
4482
|
+
const started = await client.startDeviceAgent();
|
|
4483
|
+
if (isErrorResponse(started)) {
|
|
4484
|
+
dependencies.printError(started.message);
|
|
4485
|
+
return EXIT_FAILED;
|
|
4486
|
+
}
|
|
4487
|
+
} catch (err) {
|
|
4488
|
+
dependencies.printError(daemonFailureMessage(err, daemon));
|
|
4489
|
+
return EXIT_FAILED;
|
|
4490
|
+
}
|
|
4491
|
+
return waitUntilConnected(client, daemon, dependencies);
|
|
4492
|
+
}
|
|
4493
|
+
async function stopAgentInBackground(dependencies) {
|
|
4494
|
+
const reading = await readBackgroundAgent(dependencies);
|
|
4495
|
+
if (!reading || !isAgentRunning(reading.status)) {
|
|
4496
|
+
dependencies.print("No device agent is running in the background.");
|
|
4497
|
+
return EXIT_OK;
|
|
4498
|
+
}
|
|
4499
|
+
if (!await stopReadAgent(reading, dependencies)) {
|
|
4500
|
+
return EXIT_FAILED;
|
|
4501
|
+
}
|
|
4502
|
+
dependencies.printSuccess("Stopped. The device shows offline in the dashboard.");
|
|
4503
|
+
return EXIT_OK;
|
|
4504
|
+
}
|
|
4505
|
+
async function stopAgentBeforeRemove(dependencies) {
|
|
4506
|
+
const reading = await readBackgroundAgent(dependencies);
|
|
4507
|
+
if (!reading || !isAgentRunning(reading.status)) {
|
|
4508
|
+
return true;
|
|
4509
|
+
}
|
|
4510
|
+
return stopReadAgent(reading, dependencies);
|
|
4511
|
+
}
|
|
4512
|
+
async function foregroundRefusal(dependencies) {
|
|
4513
|
+
const reading = await readBackgroundAgent(dependencies);
|
|
4514
|
+
if (!reading || !isAgentRunning(reading.status)) {
|
|
4515
|
+
return null;
|
|
4516
|
+
}
|
|
4517
|
+
const device = reading.status.deviceAgentId ? `device ${reading.status.deviceAgentId}` : "a device";
|
|
4518
|
+
return `This machine already runs as ${device} in the background. Stop it first: pinggy device stop`;
|
|
4519
|
+
}
|
|
4520
|
+
async function printBackgroundStatus(dependencies) {
|
|
4521
|
+
const reading = await readBackgroundAgent(dependencies);
|
|
4522
|
+
if (!reading) {
|
|
4523
|
+
return;
|
|
4524
|
+
}
|
|
4525
|
+
dependencies.print(` Background ${describeAgent(reading)}`);
|
|
4526
|
+
if (reading.status.lastError) {
|
|
4527
|
+
dependencies.print(` Last error ${reading.status.lastError.message}`);
|
|
4528
|
+
}
|
|
4529
|
+
}
|
|
4530
|
+
function describeAgentForDaemonStatus(status) {
|
|
4531
|
+
switch (status.state) {
|
|
4532
|
+
case DeviceAgentState.Connected:
|
|
4533
|
+
return `connected as ${status.deviceAgentId}`;
|
|
4534
|
+
case DeviceAgentState.Connecting:
|
|
4535
|
+
return "connecting";
|
|
4536
|
+
case DeviceAgentState.Reconnecting:
|
|
4537
|
+
return "reconnecting";
|
|
4538
|
+
default:
|
|
4539
|
+
return "not running";
|
|
4540
|
+
}
|
|
4541
|
+
}
|
|
4542
|
+
function describeAgent({ daemon, status }) {
|
|
4543
|
+
const host = daemon.host === DaemonHost.APP ? "inside the Pinggy app" : `in the daemon, PID ${daemon.pid}`;
|
|
4544
|
+
switch (status.state) {
|
|
4545
|
+
case DeviceAgentState.Connected:
|
|
4546
|
+
return `connected since ${status.connectedAt}, ${host}`;
|
|
4547
|
+
case DeviceAgentState.Connecting:
|
|
4548
|
+
return `connecting, ${host}`;
|
|
4549
|
+
case DeviceAgentState.Reconnecting:
|
|
4550
|
+
return `reconnecting, ${host}`;
|
|
4551
|
+
default:
|
|
4552
|
+
return "not running";
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
async function stopReadAgent(reading, dependencies) {
|
|
4556
|
+
try {
|
|
4557
|
+
await dependencies.client(reading.daemon.port).stopDeviceAgent();
|
|
4558
|
+
return true;
|
|
4559
|
+
} catch (err) {
|
|
4560
|
+
dependencies.printError(`Could not stop the background device agent: ${errorMessage(err)}`);
|
|
4561
|
+
return false;
|
|
4562
|
+
}
|
|
4563
|
+
}
|
|
4564
|
+
function daemonFailureMessage(err, daemon) {
|
|
4565
|
+
if (err instanceof IPCHttpError && err.statusCode === HTTP_NOT_FOUND) {
|
|
4566
|
+
return olderDaemonMessage(daemon);
|
|
4567
|
+
}
|
|
4568
|
+
return `Could not reach the daemon: ${errorMessage(err)}`;
|
|
4569
|
+
}
|
|
4570
|
+
async function waitUntilConnected(client, daemon, dependencies) {
|
|
4571
|
+
const deadline = dependencies.now() + CONNECT_WAIT_MILLIS;
|
|
4572
|
+
for (; ; ) {
|
|
4573
|
+
let status;
|
|
4574
|
+
try {
|
|
4575
|
+
status = await client.getDeviceAgentStatus();
|
|
4576
|
+
} catch (err) {
|
|
4577
|
+
dependencies.printError(daemonFailureMessage(err, daemon));
|
|
4578
|
+
return EXIT_FAILED;
|
|
4579
|
+
}
|
|
4580
|
+
if (status.state === DeviceAgentState.Connected) {
|
|
4581
|
+
printRunning(status, daemon, dependencies);
|
|
4582
|
+
return EXIT_OK;
|
|
4583
|
+
}
|
|
4584
|
+
if (status.state === DeviceAgentState.Stopped) {
|
|
4585
|
+
dependencies.printError(status.lastError?.message ?? "The device agent stopped before it connected.");
|
|
4586
|
+
return EXIT_FAILED;
|
|
4587
|
+
}
|
|
4588
|
+
if (dependencies.now() >= deadline) {
|
|
4589
|
+
dependencies.printWarning("Still connecting in the background. Check with: pinggy device status");
|
|
4590
|
+
return EXIT_OK;
|
|
4591
|
+
}
|
|
4592
|
+
await dependencies.sleep(POLL_INTERVAL_MILLIS);
|
|
4593
|
+
}
|
|
4594
|
+
}
|
|
4595
|
+
function printRunning(status, daemon, dependencies) {
|
|
4596
|
+
dependencies.printSuccess(`Running in the background as device ${status.deviceAgentId}`);
|
|
4597
|
+
if (status.email) {
|
|
4598
|
+
dependencies.print(`Signed in as ${status.email}`);
|
|
4599
|
+
}
|
|
4600
|
+
if (status.deviceUrl) {
|
|
4601
|
+
dependencies.print("Open the device in a browser:");
|
|
4602
|
+
dependencies.print(` ${status.deviceUrl}`);
|
|
4603
|
+
}
|
|
4604
|
+
if (daemon.host === DaemonHost.APP) {
|
|
4605
|
+
dependencies.print("It runs inside the Pinggy app, and stops when the app quits.");
|
|
4606
|
+
}
|
|
4607
|
+
dependencies.print(`Logs: ${dependencies.daemonLogPath()}`);
|
|
4608
|
+
dependencies.print("Stop it with: pinggy device stop");
|
|
4609
|
+
}
|
|
4610
|
+
|
|
4611
|
+
// src/cli/subcommand/handlers/daemonCommandsHandler.ts
|
|
4612
|
+
async function handleDaemon(args) {
|
|
4613
|
+
if (args.length === 0) {
|
|
4614
|
+
printDaemonHelp();
|
|
4615
|
+
return;
|
|
4616
|
+
}
|
|
4617
|
+
const verb = args[0];
|
|
4618
|
+
switch (verb) {
|
|
4619
|
+
case "start":
|
|
4620
|
+
await handleDaemonStart();
|
|
4621
|
+
return;
|
|
4622
|
+
case "stop":
|
|
4623
|
+
await handleDaemonStop();
|
|
4624
|
+
return;
|
|
4625
|
+
case "status":
|
|
4626
|
+
await handleDaemonStatus();
|
|
4627
|
+
return;
|
|
4628
|
+
case "service-install":
|
|
4629
|
+
installService();
|
|
4630
|
+
return;
|
|
4631
|
+
case "service-uninstall":
|
|
4632
|
+
uninstallService();
|
|
4633
|
+
return;
|
|
4634
|
+
default:
|
|
4635
|
+
printer_default.error(`Unknown daemon command: "${verb}"`);
|
|
4636
|
+
printDaemonHelp();
|
|
4637
|
+
return;
|
|
4638
|
+
}
|
|
4639
|
+
}
|
|
4640
|
+
async function handleDaemonStart() {
|
|
4641
|
+
const info = getDaemonInfo();
|
|
4642
|
+
if (info) {
|
|
4643
|
+
if (!isIpcCompatible(info)) {
|
|
4644
|
+
printer_default.error(ipcMismatchMessage(info));
|
|
4645
|
+
process.exit(1);
|
|
4646
|
+
}
|
|
4647
|
+
printer_default.print(pico2.yellow(`Daemon already running (PID ${info.pid}, port ${info.port}). Use: pinggy daemon status for details.`));
|
|
4648
|
+
return;
|
|
4649
|
+
}
|
|
4650
|
+
const autoConfigs = getAutoStartConfigs();
|
|
4651
|
+
if (autoConfigs.length > 0) {
|
|
4652
|
+
printer_default.print(pico2.cyanBright(`Starting daemon with ${autoConfigs.length} auto-start tunnel(s):`));
|
|
4653
|
+
for (const c of autoConfigs) {
|
|
4654
|
+
printer_default.print(` ${pico2.bold(c.name)} (${c.configId.slice(0, 8)})`);
|
|
4655
|
+
}
|
|
4656
|
+
} else {
|
|
4657
|
+
printer_default.print(pico2.cyanBright("Starting daemon..."));
|
|
4658
|
+
}
|
|
4659
|
+
try {
|
|
4660
|
+
const info2 = await startDaemon();
|
|
4661
|
+
printer_default.success(`Daemon started PID ${info2.pid}.`);
|
|
4662
|
+
} catch (err) {
|
|
4663
|
+
printer_default.error(`Failed to start daemon: ${errorMessage(err)}`);
|
|
4664
|
+
process.exit(1);
|
|
4665
|
+
}
|
|
4666
|
+
}
|
|
4667
|
+
async function handleDaemonStop() {
|
|
4668
|
+
if (!isDaemonRunning()) {
|
|
4669
|
+
printer_default.print(pico2.yellow("No daemon is running."));
|
|
4670
|
+
return;
|
|
4671
|
+
}
|
|
4672
|
+
const agent = await readBackgroundAgent(machineBackgroundAgentDependencies());
|
|
4673
|
+
const result = await stopDaemon();
|
|
4674
|
+
if (result.ok) {
|
|
4675
|
+
printer_default.success("Daemon stopped.");
|
|
4676
|
+
if (agent && isAgentRunning(agent.status)) {
|
|
4677
|
+
printer_default.print("The background device agent stopped too. The device shows offline in the dashboard.");
|
|
4678
|
+
}
|
|
4679
|
+
} else if (result.reason === DaemonHost.APP) {
|
|
4680
|
+
printer_default.print(pico2.yellow("The Pinggy daemon is running inside the Pinggy desktop app. Please quit the Pinggy app to stop it."));
|
|
4681
|
+
} else {
|
|
4682
|
+
printer_default.error(result.error);
|
|
4683
|
+
}
|
|
4684
|
+
}
|
|
4685
|
+
async function handleDaemonStatus() {
|
|
4686
|
+
const info = getDaemonInfo();
|
|
4687
|
+
if (!info) {
|
|
4688
|
+
printer_default.print(pico2.yellow("No daemon is running. Start with: pinggy daemon start"));
|
|
4689
|
+
return;
|
|
4690
|
+
}
|
|
4691
|
+
const uptimeSec = Math.floor((Date.now() - new Date(info.startedAt).getTime()) / 1e3);
|
|
4692
|
+
const h = Math.floor(uptimeSec / 3600);
|
|
4693
|
+
const m = Math.floor(uptimeSec % 3600 / 60);
|
|
4694
|
+
const s = uptimeSec % 60;
|
|
4695
|
+
const uptimeStr = h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
4696
|
+
printer_default.print(pico2.cyanBright("Daemon Status"));
|
|
4697
|
+
printer_default.print(` PID: ${info.pid}`);
|
|
4698
|
+
printer_default.print(` Port: ${info.port}`);
|
|
4699
|
+
printer_default.print(` Started: ${info.startedAt}`);
|
|
4700
|
+
printer_default.print(` Uptime: ${uptimeStr}`);
|
|
4701
|
+
printer_default.print(` Host: ${info.host === DaemonHost.APP ? "Pinggy app (in-process)" : "standalone (CLI)"}`);
|
|
4702
|
+
printer_default.print(` IPC: v${daemonIpcVersion(info)}`);
|
|
4703
|
+
if (!isIpcCompatible(info)) {
|
|
4704
|
+
printer_default.print(pico2.yellow(` Warning: ${ipcMismatchMessage(info)}`));
|
|
4705
|
+
}
|
|
4706
|
+
const agent = await readBackgroundAgent(machineBackgroundAgentDependencies());
|
|
4707
|
+
if (agent) {
|
|
4708
|
+
printer_default.print(` Device: ${describeAgentForDaemonStatus(agent.status)}`);
|
|
4709
|
+
}
|
|
4710
|
+
}
|
|
4711
|
+
|
|
4712
|
+
// src/cli/subcommand/handlers/psCommand.ts
|
|
4713
|
+
import pico3 from "picocolors";
|
|
4714
|
+
async function handlePs() {
|
|
4715
|
+
const client = new TunnelClient();
|
|
4716
|
+
try {
|
|
4717
|
+
await client.ensureDaemon();
|
|
4718
|
+
} catch (err) {
|
|
4719
|
+
printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
|
|
4720
|
+
return;
|
|
4721
|
+
}
|
|
4722
|
+
try {
|
|
4723
|
+
const tunnels = await client.handleListV2();
|
|
4724
|
+
if (isErrorResponse(tunnels)) {
|
|
4725
|
+
printer_default.error(`Failed to list tunnels: ${tunnels.message}`);
|
|
4726
|
+
return;
|
|
4727
|
+
}
|
|
4728
|
+
if (tunnels.length === 0) {
|
|
4729
|
+
printer_default.print("No tunnels running.");
|
|
4730
|
+
return;
|
|
4731
|
+
}
|
|
4732
|
+
const header = `${pad("ID", 14)} ${pad("NAME", 16)} ${pad("STATUS", 10)} ${pad("LOCAL", 20)} URL`;
|
|
4733
|
+
printer_default.print(pico3.gray(header));
|
|
4734
|
+
printer_default.print(pico3.gray("\u2500".repeat(90)));
|
|
4735
|
+
for (const t of tunnels) {
|
|
4736
|
+
const id = t.tunnelid.slice(0, 12);
|
|
4737
|
+
const name = t.tunnelconfig?.name || "-";
|
|
4738
|
+
const status = t.status.state;
|
|
4739
|
+
const local = getLocalAddress(t.tunnelconfig);
|
|
4740
|
+
const url = t.remoteurls?.[0] || "-";
|
|
4741
|
+
const statusColor = status === "running" ? pico3.green : status === "starting" ? pico3.yellow : pico3.red;
|
|
4742
|
+
printer_default.print(
|
|
4743
|
+
`${pico3.cyan(pad(id, 14))} ${pad(name, 16)} ${statusColor(pad(status, 10))} ${pad(local, 20)} ${pico3.magentaBright(url)}`
|
|
4744
|
+
);
|
|
4745
|
+
}
|
|
4746
|
+
} catch (err) {
|
|
4747
|
+
printer_default.error(`Failed to list tunnels: ${errorMessage(err)}`);
|
|
4748
|
+
} finally {
|
|
4749
|
+
client.close();
|
|
4750
|
+
}
|
|
4751
|
+
}
|
|
4752
|
+
function pad(str, len) {
|
|
4753
|
+
return str.length >= len ? str.slice(0, len) : str + " ".repeat(len - str.length);
|
|
4754
|
+
}
|
|
4755
|
+
|
|
4756
|
+
// src/cli/subcommand/handlers/stopCommand.ts
|
|
4757
|
+
async function handleStop(args) {
|
|
4758
|
+
if (args.length === 0) {
|
|
4759
|
+
printer_default.error("Usage: pinggy stop <name|id> [<name|id> ...]");
|
|
4760
|
+
return;
|
|
4761
|
+
}
|
|
4762
|
+
const client = new TunnelClient();
|
|
4763
|
+
try {
|
|
4764
|
+
await client.ensureDaemon();
|
|
4765
|
+
} catch (err) {
|
|
4766
|
+
printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
|
|
4767
|
+
return;
|
|
4768
|
+
}
|
|
4769
|
+
try {
|
|
4770
|
+
const tunnels = await client.handleListV2();
|
|
4771
|
+
if (isErrorResponse(tunnels)) {
|
|
4772
|
+
printer_default.error(`Failed to list tunnels: ${tunnels.message}`);
|
|
4773
|
+
return;
|
|
4774
|
+
}
|
|
4775
|
+
const targets = resolveTargets(tunnels, args);
|
|
4776
|
+
if (targets.matched.length === 0) {
|
|
4777
|
+
printer_default.error(`No tunnel found matching: ${args.join(", ")}. Use: pinggy ps`);
|
|
4778
|
+
return;
|
|
4779
|
+
}
|
|
4780
|
+
for (const { input, tunnel } of targets.matched) {
|
|
4781
|
+
const result = await client.handleStop(tunnel.tunnelid);
|
|
4782
|
+
if (isErrorResponse(result)) {
|
|
4783
|
+
printer_default.error(`Failed to stop "${input}": ${result.message}`);
|
|
4784
|
+
continue;
|
|
4785
|
+
}
|
|
4786
|
+
const name = tunnel.tunnelconfig.name || tunnel.tunnelid.slice(0, 12);
|
|
4787
|
+
printer_default.success(`Tunnel "${name}" stopped.`);
|
|
4788
|
+
}
|
|
4789
|
+
for (const m of targets.missing) {
|
|
4790
|
+
printer_default.warn(`No tunnel found matching "${m}".`);
|
|
4791
|
+
}
|
|
4792
|
+
} catch (err) {
|
|
4793
|
+
printer_default.error(`Failed to stop tunnel: ${errorMessage(err)}`);
|
|
4794
|
+
} finally {
|
|
4795
|
+
client.close();
|
|
4796
|
+
}
|
|
4797
|
+
}
|
|
4798
|
+
function resolveTargets(tunnels, args) {
|
|
4799
|
+
if (args.length > 1) {
|
|
4800
|
+
const input = args.join(" ");
|
|
4801
|
+
const tunnel = findTunnel(tunnels, input);
|
|
4802
|
+
if (tunnel) return { matched: [{ input, tunnel }], missing: [] };
|
|
4803
|
+
}
|
|
4804
|
+
const matched = [];
|
|
4805
|
+
const missing = [];
|
|
4806
|
+
for (const input of args) {
|
|
4807
|
+
const tunnel = findTunnel(tunnels, input);
|
|
4808
|
+
if (tunnel) matched.push({ input, tunnel });
|
|
4809
|
+
else missing.push(input);
|
|
4810
|
+
}
|
|
4811
|
+
return { matched, missing };
|
|
4812
|
+
}
|
|
4813
|
+
|
|
4814
|
+
// src/cli/subcommand/handlers/attachCommand.ts
|
|
4815
|
+
import pico4 from "picocolors";
|
|
4816
|
+
async function handleAttach(args) {
|
|
4817
|
+
if (args.length === 0) {
|
|
4818
|
+
printer_default.error("Usage: pinggy attach <name|id>");
|
|
4819
|
+
return;
|
|
4820
|
+
}
|
|
4821
|
+
const nameOrId = args[0];
|
|
4822
|
+
const client = new TunnelClient();
|
|
4823
|
+
try {
|
|
4824
|
+
await client.ensureDaemon();
|
|
4825
|
+
} catch (err) {
|
|
4826
|
+
printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
|
|
4827
|
+
return;
|
|
4828
|
+
}
|
|
4829
|
+
try {
|
|
1972
4830
|
const tunnels = await client.handleListV2();
|
|
1973
4831
|
if (isErrorResponse(tunnels)) {
|
|
1974
4832
|
printer_default.error(`Failed to list tunnels: ${tunnels.message}`);
|
|
@@ -2010,7 +4868,7 @@ async function handleAttach(args) {
|
|
|
2010
4868
|
}
|
|
2011
4869
|
|
|
2012
4870
|
// src/cli/subcommand/handlers/logsCommand.ts
|
|
2013
|
-
import
|
|
4871
|
+
import fs8 from "fs";
|
|
2014
4872
|
async function handleLogs(args, follow) {
|
|
2015
4873
|
const arg = args.find((a) => !a.startsWith("-"));
|
|
2016
4874
|
const client = new TunnelClient();
|
|
@@ -2050,7 +4908,7 @@ async function handleLogs(args, follow) {
|
|
|
2050
4908
|
return;
|
|
2051
4909
|
}
|
|
2052
4910
|
client.close();
|
|
2053
|
-
if (!filePath || !
|
|
4911
|
+
if (!filePath || !fs8.existsSync(filePath)) {
|
|
2054
4912
|
printer_default.error(`Log file not found: ${filePath}`);
|
|
2055
4913
|
process.exit(1);
|
|
2056
4914
|
}
|
|
@@ -2061,18 +4919,18 @@ async function handleLogs(args, follow) {
|
|
|
2061
4919
|
}
|
|
2062
4920
|
}
|
|
2063
4921
|
function printTail(filePath, lines) {
|
|
2064
|
-
const content =
|
|
4922
|
+
const content = fs8.readFileSync(filePath, "utf-8");
|
|
2065
4923
|
const allLines = content.split("\n");
|
|
2066
4924
|
const tail = allLines.slice(-lines).join("\n");
|
|
2067
4925
|
process.stdout.write(tail);
|
|
2068
4926
|
if (!tail.endsWith("\n")) process.stdout.write("\n");
|
|
2069
4927
|
}
|
|
2070
4928
|
async function followFile(filePath) {
|
|
2071
|
-
if (
|
|
2072
|
-
let fileSize =
|
|
4929
|
+
if (fs8.existsSync(filePath)) printTail(filePath, 20);
|
|
4930
|
+
let fileSize = fs8.existsSync(filePath) ? fs8.statSync(filePath).size : 0;
|
|
2073
4931
|
let watcher = null;
|
|
2074
4932
|
const openStream = () => {
|
|
2075
|
-
const stream =
|
|
4933
|
+
const stream = fs8.createReadStream(filePath, { start: fileSize, encoding: "utf-8" });
|
|
2076
4934
|
stream.on("data", (chunk) => {
|
|
2077
4935
|
process.stdout.write(chunk);
|
|
2078
4936
|
fileSize += Buffer.byteLength(chunk, "utf-8");
|
|
@@ -2080,9 +4938,9 @@ async function followFile(filePath) {
|
|
|
2080
4938
|
stream.on("error", () => {
|
|
2081
4939
|
});
|
|
2082
4940
|
};
|
|
2083
|
-
watcher =
|
|
2084
|
-
if (
|
|
2085
|
-
const stat =
|
|
4941
|
+
watcher = fs8.watch(filePath, { persistent: true }, (event2) => {
|
|
4942
|
+
if (event2 === "change") {
|
|
4943
|
+
const stat = fs8.existsSync(filePath) ? fs8.statSync(filePath) : null;
|
|
2086
4944
|
if (!stat || stat.size < fileSize) {
|
|
2087
4945
|
fileSize = 0;
|
|
2088
4946
|
}
|
|
@@ -2224,6 +5082,145 @@ async function handleRestart(args) {
|
|
|
2224
5082
|
}
|
|
2225
5083
|
}
|
|
2226
5084
|
|
|
5085
|
+
// src/cli/subcommand/handlers/deviceCommand.ts
|
|
5086
|
+
import pico5 from "picocolors";
|
|
5087
|
+
var DeviceVerb = {
|
|
5088
|
+
Login: "login",
|
|
5089
|
+
Connect: "connect",
|
|
5090
|
+
Stop: "stop",
|
|
5091
|
+
Status: "status",
|
|
5092
|
+
Remove: "remove"
|
|
5093
|
+
};
|
|
5094
|
+
var DEVICE_AGENT_TOKEN_VARIABLE = "DEVICE_AGENT_TOKEN";
|
|
5095
|
+
function takeEnvironmentToken() {
|
|
5096
|
+
const value = process.env[DEVICE_AGENT_TOKEN_VARIABLE]?.trim();
|
|
5097
|
+
delete process.env[DEVICE_AGENT_TOKEN_VARIABLE];
|
|
5098
|
+
return value || void 0;
|
|
5099
|
+
}
|
|
5100
|
+
function resolveConnectTarget(flagToken, environmentToken, manage, stored) {
|
|
5101
|
+
if (flagToken) {
|
|
5102
|
+
return { token: flagToken, manage: manage ?? stored?.server };
|
|
5103
|
+
}
|
|
5104
|
+
if (environmentToken) {
|
|
5105
|
+
return { token: environmentToken, manage };
|
|
5106
|
+
}
|
|
5107
|
+
if (stored?.token) {
|
|
5108
|
+
return { token: stored.token, manage: manage ?? stored.server };
|
|
5109
|
+
}
|
|
5110
|
+
return null;
|
|
5111
|
+
}
|
|
5112
|
+
async function handleDevice(args, background = machineBackgroundAgentDependencies()) {
|
|
5113
|
+
const environmentToken = takeEnvironmentToken();
|
|
5114
|
+
if (args.length === 0) {
|
|
5115
|
+
printDeviceHelp();
|
|
5116
|
+
return;
|
|
5117
|
+
}
|
|
5118
|
+
const verb = args[0];
|
|
5119
|
+
const flagArgs = args.slice(1);
|
|
5120
|
+
const { values } = parseCliArgs(cliOptions, flagArgs);
|
|
5121
|
+
configureLogger(values);
|
|
5122
|
+
const inBackground = Boolean(values.b);
|
|
5123
|
+
switch (verb) {
|
|
5124
|
+
case DeviceVerb.Login:
|
|
5125
|
+
exitUnlessOk(await handleDeviceLogin(values.manage, values.name, inBackground, background));
|
|
5126
|
+
return;
|
|
5127
|
+
case DeviceVerb.Connect:
|
|
5128
|
+
exitUnlessOk(await handleDeviceConnect(
|
|
5129
|
+
values.token,
|
|
5130
|
+
environmentToken,
|
|
5131
|
+
values.manage,
|
|
5132
|
+
inBackground,
|
|
5133
|
+
background
|
|
5134
|
+
));
|
|
5135
|
+
return;
|
|
5136
|
+
case DeviceVerb.Stop:
|
|
5137
|
+
exitUnlessOk(await stopAgentInBackground(background));
|
|
5138
|
+
return;
|
|
5139
|
+
case DeviceVerb.Status:
|
|
5140
|
+
await handleDeviceStatus(background);
|
|
5141
|
+
return;
|
|
5142
|
+
case DeviceVerb.Remove:
|
|
5143
|
+
exitUnlessOk(await handleDeviceRemove(background));
|
|
5144
|
+
return;
|
|
5145
|
+
default:
|
|
5146
|
+
if (verb === "help" || verb === "--help" || verb === "-h") {
|
|
5147
|
+
printDeviceHelp();
|
|
5148
|
+
return;
|
|
5149
|
+
}
|
|
5150
|
+
printer_default.error(`Unknown command "${verb}". Valid commands: login, connect, stop, status, remove.`);
|
|
5151
|
+
process.exit(1);
|
|
5152
|
+
}
|
|
5153
|
+
}
|
|
5154
|
+
function exitUnlessOk(exitCode) {
|
|
5155
|
+
if (exitCode !== EXIT_OK) {
|
|
5156
|
+
process.exit(exitCode);
|
|
5157
|
+
}
|
|
5158
|
+
}
|
|
5159
|
+
function refuseDevicesSpelling(args) {
|
|
5160
|
+
printer_default.error(devicesSpellingMessage(args));
|
|
5161
|
+
process.exit(1);
|
|
5162
|
+
}
|
|
5163
|
+
function devicesSpellingMessage(args) {
|
|
5164
|
+
const verbs = Object.values(DeviceVerb);
|
|
5165
|
+
const suggestion = verbs.includes(args[0]) ? args[0] : "--help";
|
|
5166
|
+
return `"pinggy devices" is now "pinggy device". Run: pinggy device ${suggestion}`;
|
|
5167
|
+
}
|
|
5168
|
+
async function mayRunInForeground(background) {
|
|
5169
|
+
const refusal = await foregroundRefusal(background);
|
|
5170
|
+
if (refusal) {
|
|
5171
|
+
printer_default.error(refusal);
|
|
5172
|
+
return false;
|
|
5173
|
+
}
|
|
5174
|
+
return true;
|
|
5175
|
+
}
|
|
5176
|
+
async function handleDeviceLogin(manage, name, inBackground, background) {
|
|
5177
|
+
if (!inBackground) {
|
|
5178
|
+
if (!await mayRunInForeground(background)) return EXIT_FAILED;
|
|
5179
|
+
return runDeviceLogin({ manage, name });
|
|
5180
|
+
}
|
|
5181
|
+
return runDeviceLogin({ manage, name }, {
|
|
5182
|
+
...machineDeviceLoginDependencies(),
|
|
5183
|
+
runAgent: (token, agentManage) => startAgentInBackground({ token, manage: agentManage }, background)
|
|
5184
|
+
});
|
|
5185
|
+
}
|
|
5186
|
+
async function handleDeviceConnect(flagToken, environmentToken, manage, inBackground, background) {
|
|
5187
|
+
const target = resolveConnectTarget(flagToken, environmentToken, manage, background.readIdentity());
|
|
5188
|
+
if (!target) {
|
|
5189
|
+
printer_default.error("This machine is not enrolled. Sign it in with your email:\n pinggy device login\nOr add a device in the dashboard, then run:\n pinggy device connect --token <TOKEN>");
|
|
5190
|
+
return EXIT_FAILED;
|
|
5191
|
+
}
|
|
5192
|
+
if (inBackground) {
|
|
5193
|
+
return startAgentInBackground(target, background);
|
|
5194
|
+
}
|
|
5195
|
+
if (!await mayRunInForeground(background)) return EXIT_FAILED;
|
|
5196
|
+
await runDeviceAgent(target.token, target.manage);
|
|
5197
|
+
return EXIT_OK;
|
|
5198
|
+
}
|
|
5199
|
+
async function handleDeviceStatus(background) {
|
|
5200
|
+
const identity = background.readIdentity();
|
|
5201
|
+
if (!identity) {
|
|
5202
|
+
printer_default.print("This machine is not enrolled. Run: pinggy device login");
|
|
5203
|
+
return;
|
|
5204
|
+
}
|
|
5205
|
+
printer_default.print(` Device id ${identity.device_agent_id ?? pico5.gray("not yet assigned")}`);
|
|
5206
|
+
printer_default.print(` Server ${identity.server}`);
|
|
5207
|
+
printer_default.print(` Token ${maskToken(identity.token)}`);
|
|
5208
|
+
printer_default.print(` Enrolled ${identity.enrolled_at ?? pico5.gray("never")}`);
|
|
5209
|
+
await printBackgroundStatus(background);
|
|
5210
|
+
}
|
|
5211
|
+
async function handleDeviceRemove(background) {
|
|
5212
|
+
if (!await stopAgentBeforeRemove(background)) {
|
|
5213
|
+
return EXIT_FAILED;
|
|
5214
|
+
}
|
|
5215
|
+
if (clearDeviceIdentity()) {
|
|
5216
|
+
printer_default.success("Local device credential removed.");
|
|
5217
|
+
printer_default.print(pico5.gray("The device still exists in the dashboard. Delete it there to revoke it."));
|
|
5218
|
+
} else {
|
|
5219
|
+
printer_default.print("This machine is not enrolled.");
|
|
5220
|
+
}
|
|
5221
|
+
return EXIT_OK;
|
|
5222
|
+
}
|
|
5223
|
+
|
|
2227
5224
|
// src/cli/subcommand/subcommands.ts
|
|
2228
5225
|
var SUBCOMMAND_SET = new Set(SUBCOMMANDS);
|
|
2229
5226
|
function isSubcommand(rawArgs) {
|
|
@@ -2264,6 +5261,12 @@ async function handleSubcommand(rawArgs) {
|
|
|
2264
5261
|
case Subcommand.Restart:
|
|
2265
5262
|
await handleRestart(rest);
|
|
2266
5263
|
return;
|
|
5264
|
+
case Subcommand.Device:
|
|
5265
|
+
await handleDevice(rest);
|
|
5266
|
+
return;
|
|
5267
|
+
case Subcommand.Devices:
|
|
5268
|
+
refuseDevicesSpelling(rest);
|
|
5269
|
+
return;
|
|
2267
5270
|
}
|
|
2268
5271
|
}
|
|
2269
5272
|
function handleConfig(args) {
|
|
@@ -2486,17 +5489,17 @@ async function initRemoteManagementBackground(values) {
|
|
|
2486
5489
|
}
|
|
2487
5490
|
|
|
2488
5491
|
// src/daemon/lifecycle/daemonChild.ts
|
|
2489
|
-
import
|
|
5492
|
+
import fs11 from "fs";
|
|
2490
5493
|
|
|
2491
5494
|
// src/daemon/ipc/ipcServer.ts
|
|
2492
5495
|
import http from "http";
|
|
2493
|
-
import
|
|
2494
|
-
import
|
|
2495
|
-
import { WebSocketServer, WebSocket } from "ws";
|
|
5496
|
+
import fs10 from "fs";
|
|
5497
|
+
import path5 from "path";
|
|
5498
|
+
import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
|
|
2496
5499
|
|
|
2497
5500
|
// src/daemon/ws/wsProtocol.ts
|
|
2498
|
-
function createTunnelEvent(tunnelId,
|
|
2499
|
-
return { type: "tunnel_event", tunnelId, event, payload };
|
|
5501
|
+
function createTunnelEvent(tunnelId, event2, payload) {
|
|
5502
|
+
return { type: "tunnel_event", tunnelId, event: event2, payload };
|
|
2500
5503
|
}
|
|
2501
5504
|
function parseClientMessage(raw) {
|
|
2502
5505
|
try {
|
|
@@ -2518,17 +5521,17 @@ function parseClientMessage(raw) {
|
|
|
2518
5521
|
}
|
|
2519
5522
|
|
|
2520
5523
|
// src/daemon/lifecycle/stateStore.ts
|
|
2521
|
-
import
|
|
2522
|
-
import
|
|
5524
|
+
import fs9 from "fs";
|
|
5525
|
+
import path4 from "path";
|
|
2523
5526
|
var STATE_FILENAME = "daemon-state.json";
|
|
2524
5527
|
function getStatePath() {
|
|
2525
|
-
return
|
|
5528
|
+
return path4.join(getPinggyConfigDir(), STATE_FILENAME);
|
|
2526
5529
|
}
|
|
2527
5530
|
function loadDaemonState() {
|
|
2528
5531
|
const statePath = getStatePath();
|
|
2529
5532
|
try {
|
|
2530
|
-
if (!
|
|
2531
|
-
const raw =
|
|
5533
|
+
if (!fs9.existsSync(statePath)) return null;
|
|
5534
|
+
const raw = fs9.readFileSync(statePath, "utf-8");
|
|
2532
5535
|
const state = JSON.parse(raw);
|
|
2533
5536
|
if (!Array.isArray(state.tunnels)) return null;
|
|
2534
5537
|
return state;
|
|
@@ -2542,8 +5545,8 @@ function persistDaemonState(state) {
|
|
|
2542
5545
|
const tmpPath = statePath + ".tmp";
|
|
2543
5546
|
try {
|
|
2544
5547
|
state.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
|
|
2545
|
-
|
|
2546
|
-
|
|
5548
|
+
fs9.writeFileSync(tmpPath, JSON.stringify(state, null, 2), "utf-8");
|
|
5549
|
+
fs9.renameSync(tmpPath, statePath);
|
|
2547
5550
|
} catch (err) {
|
|
2548
5551
|
logger.error("Failed to persist daemon state", { error: errorMessage(err) });
|
|
2549
5552
|
}
|
|
@@ -2551,8 +5554,8 @@ function persistDaemonState(state) {
|
|
|
2551
5554
|
function clearDaemonState() {
|
|
2552
5555
|
const statePath = getStatePath();
|
|
2553
5556
|
try {
|
|
2554
|
-
if (
|
|
2555
|
-
|
|
5557
|
+
if (fs9.existsSync(statePath)) {
|
|
5558
|
+
fs9.unlinkSync(statePath);
|
|
2556
5559
|
}
|
|
2557
5560
|
} catch {
|
|
2558
5561
|
}
|
|
@@ -2566,7 +5569,7 @@ function removeTunnelFromState(state, tunnelId) {
|
|
|
2566
5569
|
}
|
|
2567
5570
|
|
|
2568
5571
|
// src/daemon/ipc/ipcServer.ts
|
|
2569
|
-
var VALID_ORIGINS = ["app", "cli", "remote"];
|
|
5572
|
+
var VALID_ORIGINS = ["app", "cli", "remote", "device"];
|
|
2570
5573
|
function parseOrigin(req) {
|
|
2571
5574
|
const raw = req.headers["x-pinggy-origin"];
|
|
2572
5575
|
const v = Array.isArray(raw) ? raw[0] : raw;
|
|
@@ -2599,6 +5602,7 @@ var IPCServer = class {
|
|
|
2599
5602
|
this.sessionCounter = 0;
|
|
2600
5603
|
this.onSessionDisconnect = null;
|
|
2601
5604
|
this.sessionTracker = null;
|
|
5605
|
+
this.deviceAgentHost = null;
|
|
2602
5606
|
this.ops = new TunnelOperations();
|
|
2603
5607
|
this.startedAt = Date.now();
|
|
2604
5608
|
this.server = http.createServer(this.handleRequest.bind(this));
|
|
@@ -2616,6 +5620,13 @@ var IPCServer = class {
|
|
|
2616
5620
|
setSessionTracker(st) {
|
|
2617
5621
|
this.sessionTracker = st;
|
|
2618
5622
|
}
|
|
5623
|
+
setDeviceAgentHost(host) {
|
|
5624
|
+
this.deviceAgentHost = host;
|
|
5625
|
+
}
|
|
5626
|
+
requireDeviceAgentHost() {
|
|
5627
|
+
if (!this.deviceAgentHost) throw new Error("The device agent is not available in this daemon yet");
|
|
5628
|
+
return this.deviceAgentHost;
|
|
5629
|
+
}
|
|
2619
5630
|
/**
|
|
2620
5631
|
* Get all active sessions (for SessionTracker inspection).
|
|
2621
5632
|
*/
|
|
@@ -2720,8 +5731,8 @@ var IPCServer = class {
|
|
|
2720
5731
|
const logDir = getTunnelLogDir();
|
|
2721
5732
|
const daemonPath = getDaemonLogPath();
|
|
2722
5733
|
const tunnels = [];
|
|
2723
|
-
if (
|
|
2724
|
-
const files =
|
|
5734
|
+
if (fs10.existsSync(logDir)) {
|
|
5735
|
+
const files = fs10.readdirSync(logDir).filter((f) => f.endsWith(".log") && !/\.log\.\d+$/.test(f));
|
|
2725
5736
|
const manager = TunnelManager.getInstance();
|
|
2726
5737
|
const activeIds = manager.getActiveTunnelIds();
|
|
2727
5738
|
const activeNames = /* @__PURE__ */ new Set();
|
|
@@ -2731,8 +5742,8 @@ var IPCServer = class {
|
|
|
2731
5742
|
if (n) activeNames.add(n);
|
|
2732
5743
|
}
|
|
2733
5744
|
for (const file of files) {
|
|
2734
|
-
const filePath =
|
|
2735
|
-
const stat =
|
|
5745
|
+
const filePath = path5.join(logDir, file);
|
|
5746
|
+
const stat = fs10.statSync(filePath);
|
|
2736
5747
|
const parsed = parseTunnelLogFilename(file);
|
|
2737
5748
|
if (!parsed) continue;
|
|
2738
5749
|
const running = parsed.tunnelId ? activeIds.has(parsed.tunnelId) : parsed.name ? activeNames.has(parsed.name) : false;
|
|
@@ -2748,6 +5759,10 @@ var IPCServer = class {
|
|
|
2748
5759
|
}
|
|
2749
5760
|
return { daemon: daemonPath, tunnels };
|
|
2750
5761
|
},
|
|
5762
|
+
// The request body is never read: a token or a server in it must not reach the agent.
|
|
5763
|
+
[Route.GetDeviceAgent]: () => this.requireDeviceAgentHost().status(),
|
|
5764
|
+
[Route.StartDeviceAgent]: (_req, ctx) => this.requireDeviceAgentHost().start(ctx.origin),
|
|
5765
|
+
[Route.StopDeviceAgent]: () => this.requireDeviceAgentHost().stop(),
|
|
2751
5766
|
[Route.Shutdown]: () => {
|
|
2752
5767
|
logger.info("Daemon shutdown requested via IPC");
|
|
2753
5768
|
if (getDaemonHost() === DaemonHost.APP) {
|
|
@@ -2764,6 +5779,9 @@ var IPCServer = class {
|
|
|
2764
5779
|
logger.error(`Shutdown step "${label}" failed`, { error: msg });
|
|
2765
5780
|
}
|
|
2766
5781
|
};
|
|
5782
|
+
step("stopDeviceAgent", () => {
|
|
5783
|
+
void this.deviceAgentHost?.close();
|
|
5784
|
+
});
|
|
2767
5785
|
step("removeDaemonInfo", removeDaemonInfo);
|
|
2768
5786
|
step("clearDaemonState", clearDaemonState);
|
|
2769
5787
|
step("stopAllTunnels", () => TunnelManager.getInstance().stopAllTunnels());
|
|
@@ -2796,8 +5814,8 @@ var IPCServer = class {
|
|
|
2796
5814
|
if (paramMatch) {
|
|
2797
5815
|
try {
|
|
2798
5816
|
await this.readBody(req);
|
|
2799
|
-
const
|
|
2800
|
-
this.sendJson(res, 200,
|
|
5817
|
+
const response2 = await paramMatch.invoke();
|
|
5818
|
+
this.sendJson(res, 200, response2);
|
|
2801
5819
|
} catch (err) {
|
|
2802
5820
|
const msg = errorMessage(err);
|
|
2803
5821
|
logger.error("IPC handler error", { route: `${method} ${url}`, error: msg });
|
|
@@ -2844,8 +5862,8 @@ var IPCServer = class {
|
|
|
2844
5862
|
return { status: "running", path: logPath, tunnelId: t.tunnelid, name, origin, running: true };
|
|
2845
5863
|
}
|
|
2846
5864
|
}
|
|
2847
|
-
if (
|
|
2848
|
-
const files =
|
|
5865
|
+
if (fs10.existsSync(logDir)) {
|
|
5866
|
+
const files = fs10.readdirSync(logDir).filter((f) => f.endsWith(".log") && !/\.log\.\d+$/.test(f));
|
|
2849
5867
|
const matches = [];
|
|
2850
5868
|
for (const file of files) {
|
|
2851
5869
|
const parsed = parseTunnelLogFilename(file);
|
|
@@ -2853,8 +5871,8 @@ var IPCServer = class {
|
|
|
2853
5871
|
const nameMatch = parsed.name !== void 0 && parsed.name === q;
|
|
2854
5872
|
const idMatch = parsed.tunnelId !== void 0 && (parsed.tunnelId === q || parsed.tunnelId.startsWith(q));
|
|
2855
5873
|
if (nameMatch || idMatch) {
|
|
2856
|
-
const filePath =
|
|
2857
|
-
const stat =
|
|
5874
|
+
const filePath = path5.join(logDir, file);
|
|
5875
|
+
const stat = fs10.statSync(filePath);
|
|
2858
5876
|
matches.push({ path: filePath, mtime: stat.mtimeMs, tunnelId: parsed.tunnelId, name: parsed.name, origin: parsed.origin });
|
|
2859
5877
|
}
|
|
2860
5878
|
}
|
|
@@ -3030,9 +6048,9 @@ var IPCServer = class {
|
|
|
3030
6048
|
session.subscriptions.clear();
|
|
3031
6049
|
this.sessions.delete(session.id);
|
|
3032
6050
|
}
|
|
3033
|
-
sendEvent(session, tunnelId,
|
|
3034
|
-
if (session.ws.readyState ===
|
|
3035
|
-
const msg = createTunnelEvent(tunnelId,
|
|
6051
|
+
sendEvent(session, tunnelId, event2, payload) {
|
|
6052
|
+
if (session.ws.readyState === WebSocket2.OPEN) {
|
|
6053
|
+
const msg = createTunnelEvent(tunnelId, event2, payload);
|
|
3036
6054
|
session.ws.send(JSON.stringify(msg));
|
|
3037
6055
|
}
|
|
3038
6056
|
}
|
|
@@ -3209,6 +6227,196 @@ var SessionTracker = class {
|
|
|
3209
6227
|
}
|
|
3210
6228
|
};
|
|
3211
6229
|
|
|
6230
|
+
// src/daemon/deviceAgentHost.ts
|
|
6231
|
+
var NOT_ENROLLED_MESSAGE = "This machine is not enrolled. Sign it in first: pinggy device login";
|
|
6232
|
+
function stoppedStatus() {
|
|
6233
|
+
return {
|
|
6234
|
+
state: DeviceAgentState.Stopped,
|
|
6235
|
+
deviceAgentId: null,
|
|
6236
|
+
server: null,
|
|
6237
|
+
email: null,
|
|
6238
|
+
deviceUrl: null,
|
|
6239
|
+
startedAt: null,
|
|
6240
|
+
connectedAt: null,
|
|
6241
|
+
startedBy: null,
|
|
6242
|
+
lastError: null
|
|
6243
|
+
};
|
|
6244
|
+
}
|
|
6245
|
+
var DeviceAgentHost = class {
|
|
6246
|
+
constructor(dependencies) {
|
|
6247
|
+
this.dependencies = dependencies;
|
|
6248
|
+
this.current = null;
|
|
6249
|
+
this.statusValue = stoppedStatus();
|
|
6250
|
+
/** Bumped on every start and stop. A run's reporter whose number is old belongs to a run that ended. */
|
|
6251
|
+
this.generation = 0;
|
|
6252
|
+
/** Set once the daemon shuts down. Nothing starts and nothing more reaches daemon-state.json. */
|
|
6253
|
+
this.closed = false;
|
|
6254
|
+
/** Start and stop chain on this, so 1 finishes before the next begins. */
|
|
6255
|
+
this.queue = Promise.resolve();
|
|
6256
|
+
}
|
|
6257
|
+
status() {
|
|
6258
|
+
return { ...this.statusValue };
|
|
6259
|
+
}
|
|
6260
|
+
/**
|
|
6261
|
+
* Starts the agent on what device.json holds now. A run on the same token and server is left alone.
|
|
6262
|
+
* A run on another is stopped first.
|
|
6263
|
+
*/
|
|
6264
|
+
start(startedBy) {
|
|
6265
|
+
return this.exclusive(async () => {
|
|
6266
|
+
if (this.closed) {
|
|
6267
|
+
return this.status();
|
|
6268
|
+
}
|
|
6269
|
+
const identity = this.dependencies.readIdentity();
|
|
6270
|
+
if (!identity?.token) {
|
|
6271
|
+
return newErrorResponse(ErrorCode.DeviceNotEnrolled, NOT_ENROLLED_MESSAGE);
|
|
6272
|
+
}
|
|
6273
|
+
const current = this.current;
|
|
6274
|
+
if (current && current.token === identity.token && current.server === identity.server) {
|
|
6275
|
+
return this.status();
|
|
6276
|
+
}
|
|
6277
|
+
await this.stopCurrent();
|
|
6278
|
+
this.launch(identity, startedBy);
|
|
6279
|
+
return this.status();
|
|
6280
|
+
});
|
|
6281
|
+
}
|
|
6282
|
+
/** Stops the run: the socket closes with 1000 and every shell ends. */
|
|
6283
|
+
stop() {
|
|
6284
|
+
return this.exclusive(async () => {
|
|
6285
|
+
await this.stopCurrent();
|
|
6286
|
+
return this.status();
|
|
6287
|
+
});
|
|
6288
|
+
}
|
|
6289
|
+
/**
|
|
6290
|
+
* The daemon is shutting down. Stops the run at once, outside the queue, and writes nothing to
|
|
6291
|
+
* daemon-state.json: the shutdown deletes that file, and a write after it would bring the file back.
|
|
6292
|
+
*/
|
|
6293
|
+
close() {
|
|
6294
|
+
this.closed = true;
|
|
6295
|
+
const current = this.current;
|
|
6296
|
+
this.current = null;
|
|
6297
|
+
this.generation += 1;
|
|
6298
|
+
this.statusValue = { ...this.statusValue, state: DeviceAgentState.Stopped, connectedAt: null };
|
|
6299
|
+
return current ? current.run.stop().then(() => void 0) : Promise.resolve();
|
|
6300
|
+
}
|
|
6301
|
+
exclusive(operation) {
|
|
6302
|
+
const result = this.queue.then(operation);
|
|
6303
|
+
this.queue = result.catch(() => void 0);
|
|
6304
|
+
return result;
|
|
6305
|
+
}
|
|
6306
|
+
launch(identity, startedBy) {
|
|
6307
|
+
this.generation += 1;
|
|
6308
|
+
const generation = this.generation;
|
|
6309
|
+
this.statusValue = {
|
|
6310
|
+
state: DeviceAgentState.Connecting,
|
|
6311
|
+
deviceAgentId: identity.device_agent_id,
|
|
6312
|
+
server: identity.server,
|
|
6313
|
+
email: null,
|
|
6314
|
+
deviceUrl: null,
|
|
6315
|
+
startedAt: this.dependencies.now().toISOString(),
|
|
6316
|
+
connectedAt: null,
|
|
6317
|
+
startedBy,
|
|
6318
|
+
lastError: null
|
|
6319
|
+
};
|
|
6320
|
+
const run2 = this.dependencies.startRun(identity, this.reporterFor(generation));
|
|
6321
|
+
this.current = { run: run2, token: identity.token, server: identity.server };
|
|
6322
|
+
this.dependencies.recordRunning(startedBy);
|
|
6323
|
+
logger.info("Device agent started in the daemon", { startedBy });
|
|
6324
|
+
void run2.done.then((end) => this.onRunEnded(generation, end));
|
|
6325
|
+
}
|
|
6326
|
+
async stopCurrent() {
|
|
6327
|
+
const current = this.current;
|
|
6328
|
+
if (!current) {
|
|
6329
|
+
return;
|
|
6330
|
+
}
|
|
6331
|
+
this.current = null;
|
|
6332
|
+
this.generation += 1;
|
|
6333
|
+
this.statusValue = { ...this.statusValue, state: DeviceAgentState.Stopped, connectedAt: null };
|
|
6334
|
+
if (!this.closed) {
|
|
6335
|
+
this.dependencies.recordStopped();
|
|
6336
|
+
}
|
|
6337
|
+
await current.run.stop();
|
|
6338
|
+
logger.info("Device agent stopped in the daemon");
|
|
6339
|
+
}
|
|
6340
|
+
/** The run ended on its own: the dashboard refused the credential or ended the device. */
|
|
6341
|
+
onRunEnded(generation, end) {
|
|
6342
|
+
if (generation !== this.generation) {
|
|
6343
|
+
return;
|
|
6344
|
+
}
|
|
6345
|
+
this.current = null;
|
|
6346
|
+
this.statusValue = {
|
|
6347
|
+
...this.statusValue,
|
|
6348
|
+
state: DeviceAgentState.Stopped,
|
|
6349
|
+
connectedAt: null,
|
|
6350
|
+
lastError: end.error
|
|
6351
|
+
};
|
|
6352
|
+
if (!this.closed) {
|
|
6353
|
+
this.dependencies.recordStopped();
|
|
6354
|
+
}
|
|
6355
|
+
}
|
|
6356
|
+
/** Logs every event, and keeps the latest for `GET /device`. Never logs the email or a payload. */
|
|
6357
|
+
reporterFor(generation) {
|
|
6358
|
+
const isCurrent = () => generation === this.generation;
|
|
6359
|
+
let welcomedOnce = false;
|
|
6360
|
+
const notConnected = () => welcomedOnce ? DeviceAgentState.Reconnecting : DeviceAgentState.Connecting;
|
|
6361
|
+
return {
|
|
6362
|
+
connecting: (server) => {
|
|
6363
|
+
if (!isCurrent()) return;
|
|
6364
|
+
this.statusValue = { ...this.statusValue, state: notConnected(), connectedAt: null };
|
|
6365
|
+
logger.info("Device agent connecting", { server });
|
|
6366
|
+
},
|
|
6367
|
+
connected: (welcome) => {
|
|
6368
|
+
if (!isCurrent()) return;
|
|
6369
|
+
welcomedOnce = true;
|
|
6370
|
+
this.statusValue = {
|
|
6371
|
+
...this.statusValue,
|
|
6372
|
+
state: DeviceAgentState.Connected,
|
|
6373
|
+
deviceAgentId: welcome.device_agent_id,
|
|
6374
|
+
email: welcome.email ?? null,
|
|
6375
|
+
deviceUrl: welcome.device_url ?? null,
|
|
6376
|
+
connectedAt: this.dependencies.now().toISOString()
|
|
6377
|
+
};
|
|
6378
|
+
logger.info("Device agent connected", { deviceAgentId: welcome.device_agent_id });
|
|
6379
|
+
},
|
|
6380
|
+
reconnecting: (delayMillis, attempt) => {
|
|
6381
|
+
if (!isCurrent()) return;
|
|
6382
|
+
this.statusValue = { ...this.statusValue, state: notConnected(), connectedAt: null };
|
|
6383
|
+
logger.warn("Device agent disconnected", { delayMillis, attempt });
|
|
6384
|
+
},
|
|
6385
|
+
warn: (message) => {
|
|
6386
|
+
logger.warn("Device agent warning", { message });
|
|
6387
|
+
},
|
|
6388
|
+
failed: (error) => {
|
|
6389
|
+
logger.error("Device agent ended", { code: error.code });
|
|
6390
|
+
},
|
|
6391
|
+
replacingDevice: (oldDeviceAgentId) => {
|
|
6392
|
+
logger.info("Device agent connects a new device in place of a stored one", { oldDeviceAgentId });
|
|
6393
|
+
}
|
|
6394
|
+
};
|
|
6395
|
+
}
|
|
6396
|
+
};
|
|
6397
|
+
function writeIdentityWhileOwned(identity) {
|
|
6398
|
+
const stored = readDeviceIdentity();
|
|
6399
|
+
if (stored && stored.token !== identity.token) {
|
|
6400
|
+
logger.info("Device agent left a newer device.json in place");
|
|
6401
|
+
return;
|
|
6402
|
+
}
|
|
6403
|
+
writeDeviceIdentity(identity);
|
|
6404
|
+
}
|
|
6405
|
+
function machineDeviceAgentHostDependencies(port, record) {
|
|
6406
|
+
return {
|
|
6407
|
+
readIdentity: readDeviceIdentity,
|
|
6408
|
+
startRun: (identity, reporter) => startDeviceAgent(identity.token, identity.server, {
|
|
6409
|
+
reporter,
|
|
6410
|
+
tunnelListSource: selfTunnelListSource(port),
|
|
6411
|
+
tunnelActions: selfTunnelActionDependencies(port),
|
|
6412
|
+
writeIdentity: writeIdentityWhileOwned
|
|
6413
|
+
}),
|
|
6414
|
+
recordRunning: record.recordRunning,
|
|
6415
|
+
recordStopped: record.recordStopped,
|
|
6416
|
+
now: () => /* @__PURE__ */ new Date()
|
|
6417
|
+
};
|
|
6418
|
+
}
|
|
6419
|
+
|
|
3212
6420
|
// src/daemon/lifecycle/daemonChild.ts
|
|
3213
6421
|
var daemonState = { tunnels: [], lastUpdated: "" };
|
|
3214
6422
|
var daemonHost = DaemonHost.CLI;
|
|
@@ -3223,13 +6431,13 @@ function writeDaemonInfo(info) {
|
|
|
3223
6431
|
ensurePinggyConfigDir();
|
|
3224
6432
|
const infoPath = getDaemonInfoPath();
|
|
3225
6433
|
const tmpPath = infoPath + ".tmp";
|
|
3226
|
-
|
|
3227
|
-
|
|
6434
|
+
fs11.writeFileSync(tmpPath, JSON.stringify(info, null, 2), "utf-8");
|
|
6435
|
+
fs11.renameSync(tmpPath, infoPath);
|
|
3228
6436
|
}
|
|
3229
6437
|
function removeDaemonInfo() {
|
|
3230
6438
|
try {
|
|
3231
6439
|
const infoPath = getDaemonInfoPath();
|
|
3232
|
-
if (
|
|
6440
|
+
if (fs11.existsSync(infoPath)) fs11.unlinkSync(infoPath);
|
|
3233
6441
|
} catch {
|
|
3234
6442
|
}
|
|
3235
6443
|
}
|
|
@@ -3254,6 +6462,29 @@ function trackTunnelStop(tunnelId) {
|
|
|
3254
6462
|
persistDaemonState(daemonState);
|
|
3255
6463
|
sessionTrackerRef?.removeTunnel(tunnelId);
|
|
3256
6464
|
}
|
|
6465
|
+
function trackDeviceAgentStart(startedBy) {
|
|
6466
|
+
daemonState.deviceAgent = { startedAt: (/* @__PURE__ */ new Date()).toISOString(), startedBy };
|
|
6467
|
+
persistDaemonState(daemonState);
|
|
6468
|
+
}
|
|
6469
|
+
function trackDeviceAgentStop() {
|
|
6470
|
+
if (!daemonState.deviceAgent) return;
|
|
6471
|
+
delete daemonState.deviceAgent;
|
|
6472
|
+
persistDaemonState(daemonState);
|
|
6473
|
+
}
|
|
6474
|
+
async function restoreCrashedDeviceAgent(host, saved) {
|
|
6475
|
+
try {
|
|
6476
|
+
const result = await host.start(saved.startedBy);
|
|
6477
|
+
if (isErrorResponse(result)) {
|
|
6478
|
+
logger.warn("Crash recovery: device agent not restored", { reason: result.message });
|
|
6479
|
+
trackDeviceAgentStop();
|
|
6480
|
+
return;
|
|
6481
|
+
}
|
|
6482
|
+
logger.info("Crash recovery: device agent restored");
|
|
6483
|
+
} catch (err) {
|
|
6484
|
+
logger.error("Crash recovery: device agent failed to start", { error: errorMessage(err) });
|
|
6485
|
+
trackDeviceAgentStop();
|
|
6486
|
+
}
|
|
6487
|
+
}
|
|
3257
6488
|
function trackIPCTunnelStart(tunnelId, origin, mode = SessionMode.Detached) {
|
|
3258
6489
|
if (mode === SessionMode.Foreground) return;
|
|
3259
6490
|
const manager = TunnelManager.getInstance();
|
|
@@ -3367,11 +6598,16 @@ async function runDaemonChild(opts = {}) {
|
|
|
3367
6598
|
});
|
|
3368
6599
|
ipcServer.setSessionTracker(sessionTracker);
|
|
3369
6600
|
setDaemonSessionTracker(sessionTracker);
|
|
6601
|
+
let deviceAgentHost = null;
|
|
3370
6602
|
let cleanedUp = false;
|
|
3371
6603
|
const cleanup = () => {
|
|
3372
6604
|
if (cleanedUp) return;
|
|
3373
6605
|
cleanedUp = true;
|
|
3374
6606
|
logger.info("Daemon shutting down");
|
|
6607
|
+
try {
|
|
6608
|
+
void deviceAgentHost?.close();
|
|
6609
|
+
} catch {
|
|
6610
|
+
}
|
|
3375
6611
|
try {
|
|
3376
6612
|
removeDaemonInfo();
|
|
3377
6613
|
} catch {
|
|
@@ -3417,6 +6653,15 @@ async function runDaemonChild(opts = {}) {
|
|
|
3417
6653
|
}
|
|
3418
6654
|
try {
|
|
3419
6655
|
const port = await ipcServer.listen();
|
|
6656
|
+
deviceAgentHost = new DeviceAgentHost(machineDeviceAgentHostDependencies(port, {
|
|
6657
|
+
recordRunning: trackDeviceAgentStart,
|
|
6658
|
+
recordStopped: trackDeviceAgentStop
|
|
6659
|
+
}));
|
|
6660
|
+
ipcServer.setDeviceAgentHost(deviceAgentHost);
|
|
6661
|
+
const crashedDeviceAgent = loadDaemonState()?.deviceAgent;
|
|
6662
|
+
if (crashedDeviceAgent) {
|
|
6663
|
+
daemonState.deviceAgent = crashedDeviceAgent;
|
|
6664
|
+
}
|
|
3420
6665
|
const info = {
|
|
3421
6666
|
pid: process.pid,
|
|
3422
6667
|
port,
|
|
@@ -3440,6 +6685,9 @@ async function runDaemonChild(opts = {}) {
|
|
|
3440
6685
|
} else {
|
|
3441
6686
|
logger.info("No auto-start tunnels configured");
|
|
3442
6687
|
}
|
|
6688
|
+
if (crashedDeviceAgent) {
|
|
6689
|
+
await restoreCrashedDeviceAgent(deviceAgentHost, crashedDeviceAgent);
|
|
6690
|
+
}
|
|
3443
6691
|
logger.info("Daemon ready", { pid: process.pid, port });
|
|
3444
6692
|
return {
|
|
3445
6693
|
pid: process.pid,
|
|
@@ -3463,7 +6711,7 @@ async function main() {
|
|
|
3463
6711
|
const { values, positionals, hasAnyArgs } = parseCliArgs(cliOptions);
|
|
3464
6712
|
configureLogger(values);
|
|
3465
6713
|
if (values["_daemon-child"]) {
|
|
3466
|
-
const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-
|
|
6714
|
+
const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-E73NFWKE.js");
|
|
3467
6715
|
await runDaemonChild2();
|
|
3468
6716
|
return;
|
|
3469
6717
|
}
|
|
@@ -3502,6 +6750,8 @@ export {
|
|
|
3502
6750
|
removeDaemonInfo,
|
|
3503
6751
|
trackTunnelStart,
|
|
3504
6752
|
trackTunnelStop,
|
|
6753
|
+
trackDeviceAgentStart,
|
|
6754
|
+
trackDeviceAgentStop,
|
|
3505
6755
|
trackIPCTunnelStart,
|
|
3506
6756
|
runDaemonChild
|
|
3507
6757
|
};
|