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
package/dist/index.cjs
CHANGED
|
@@ -236,6 +236,9 @@ function getTunnelLogPath(tunnelId, origin, name) {
|
|
|
236
236
|
function getDaemonLogPath() {
|
|
237
237
|
return import_path2.default.join(getPinggyLogDir(), "daemon.log");
|
|
238
238
|
}
|
|
239
|
+
function getDeviceConfigPath() {
|
|
240
|
+
return import_path2.default.join(getPinggyConfigDir(), "device.json");
|
|
241
|
+
}
|
|
239
242
|
function ensurePinggyConfigDir() {
|
|
240
243
|
const dir = getPinggyConfigDir();
|
|
241
244
|
import_fs2.default.mkdirSync(dir, { recursive: true });
|
|
@@ -521,6 +524,121 @@ var init_rotateLog = __esm({
|
|
|
521
524
|
}
|
|
522
525
|
});
|
|
523
526
|
|
|
527
|
+
// src/types.ts
|
|
528
|
+
function isErrorResponse(obj) {
|
|
529
|
+
return typeof obj === "object" && obj !== null && "code" in obj && "message" in obj && typeof obj.message === "string" && Object.values(ErrorCode).includes(obj.code);
|
|
530
|
+
}
|
|
531
|
+
function newErrorResponse(codeOrError, message) {
|
|
532
|
+
if (typeof codeOrError === "object") {
|
|
533
|
+
return codeOrError;
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
code: codeOrError,
|
|
537
|
+
message
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function NewResponseObject(data) {
|
|
541
|
+
const encoder = new TextEncoder();
|
|
542
|
+
const bytes = encoder.encode(JSON.stringify(data));
|
|
543
|
+
return {
|
|
544
|
+
response: bytes,
|
|
545
|
+
requestid: "",
|
|
546
|
+
command: "",
|
|
547
|
+
error: false,
|
|
548
|
+
errorresponse: {}
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
function NewErrorResponseObject(errorResponse) {
|
|
552
|
+
return {
|
|
553
|
+
response: new Uint8Array(),
|
|
554
|
+
requestid: "",
|
|
555
|
+
command: "",
|
|
556
|
+
error: true,
|
|
557
|
+
errorresponse: errorResponse
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function newStatus(tunnelState, errorCode, errorMsg) {
|
|
561
|
+
let assignedState = tunnelState;
|
|
562
|
+
if (tunnelState === "live" /* Live */) {
|
|
563
|
+
assignedState = "running" /* Running */;
|
|
564
|
+
} else if (tunnelState === "idle" /* New */) {
|
|
565
|
+
assignedState = "idle" /* New */;
|
|
566
|
+
} else if (tunnelState === "closed" /* Closed */) {
|
|
567
|
+
assignedState = "exited" /* Exited */;
|
|
568
|
+
}
|
|
569
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
570
|
+
return {
|
|
571
|
+
state: assignedState,
|
|
572
|
+
errorcode: errorCode,
|
|
573
|
+
errormsg: errorMsg,
|
|
574
|
+
createdtimestamp: now,
|
|
575
|
+
starttimestamp: now,
|
|
576
|
+
endtimestamp: now,
|
|
577
|
+
warnings: []
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
function newStats() {
|
|
581
|
+
return {
|
|
582
|
+
numLiveConnections: 0,
|
|
583
|
+
numTotalConnections: 0,
|
|
584
|
+
numTotalReqBytes: 0,
|
|
585
|
+
numTotalResBytes: 0,
|
|
586
|
+
numTotalTxBytes: 0,
|
|
587
|
+
elapsedTime: 0
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
var TunnelStateType, TunnelErrorCodeType, TunnelWarningCode, ErrorCode, RemoteManagementStatus;
|
|
591
|
+
var init_types = __esm({
|
|
592
|
+
"src/types.ts"() {
|
|
593
|
+
"use strict";
|
|
594
|
+
init_cjs_shims();
|
|
595
|
+
TunnelStateType = /* @__PURE__ */ ((TunnelStateType2) => {
|
|
596
|
+
TunnelStateType2["New"] = "idle";
|
|
597
|
+
TunnelStateType2["Starting"] = "starting";
|
|
598
|
+
TunnelStateType2["Running"] = "running";
|
|
599
|
+
TunnelStateType2["Live"] = "live";
|
|
600
|
+
TunnelStateType2["Closed"] = "closed";
|
|
601
|
+
TunnelStateType2["Exited"] = "exited";
|
|
602
|
+
return TunnelStateType2;
|
|
603
|
+
})(TunnelStateType || {});
|
|
604
|
+
TunnelErrorCodeType = /* @__PURE__ */ ((TunnelErrorCodeType2) => {
|
|
605
|
+
TunnelErrorCodeType2["NonResponsive"] = "non_responsive";
|
|
606
|
+
TunnelErrorCodeType2["FailedToConnect"] = "failed_to_connect";
|
|
607
|
+
TunnelErrorCodeType2["ErrorInAdditionalForwarding"] = "additional_forwarding_error";
|
|
608
|
+
TunnelErrorCodeType2["WebdebuggerError"] = "webdebugger_error";
|
|
609
|
+
TunnelErrorCodeType2["NoError"] = "";
|
|
610
|
+
return TunnelErrorCodeType2;
|
|
611
|
+
})(TunnelErrorCodeType || {});
|
|
612
|
+
TunnelWarningCode = /* @__PURE__ */ ((TunnelWarningCode3) => {
|
|
613
|
+
TunnelWarningCode3["InvalidTunnelServePath"] = "INVALID_TUNNEL_SERVE_PATH";
|
|
614
|
+
TunnelWarningCode3["UnknownWarning"] = "UNKNOWN_WARNING";
|
|
615
|
+
return TunnelWarningCode3;
|
|
616
|
+
})(TunnelWarningCode || {});
|
|
617
|
+
ErrorCode = {
|
|
618
|
+
InvalidRequestMethodError: "INVALID_REQUEST_METHOD",
|
|
619
|
+
InvalidRequestBodyError: "COULD_NOT_READ_BODY",
|
|
620
|
+
InternalServerError: "INTERNAL_SERVER_ERROR",
|
|
621
|
+
InvalidBodyFormatError: "INVALID_DATA_FORMAT",
|
|
622
|
+
ErrorStartingTunnel: "ERROR_STARTING_TUNNEL",
|
|
623
|
+
TunnelNotFound: "TUNNEL_WITH_ID_OR_CONFIG_ID_NOT_FOUND",
|
|
624
|
+
TunnelAlreadyRunningError: "TUNNEL_WITH_ID_OR_CONFIG_ID_ALREADY_RUNNING",
|
|
625
|
+
WebsocketUpgradeFailError: "WEBSOCKET_UPGRADE_FAILED",
|
|
626
|
+
RemoteManagementAlreadyRunning: "REMOTE_MANAGEMENT_ALREADY_RUNNING",
|
|
627
|
+
RemoteManagementNotRunning: "REMOTE_MANAGEMENT_NOT_RUNNING",
|
|
628
|
+
RemoteManagementDeserializationFailed: "REMOTE_MANAGEMENT_DESERIALIZATION_FAILED",
|
|
629
|
+
DeviceNotEnrolled: "DEVICE_NOT_ENROLLED"
|
|
630
|
+
};
|
|
631
|
+
RemoteManagementStatus = {
|
|
632
|
+
Connecting: "CONNECTING",
|
|
633
|
+
Disconnecting: "DISCONNECTING",
|
|
634
|
+
Reconnecting: "RECONNECTING",
|
|
635
|
+
Running: "RUNNING",
|
|
636
|
+
NotRunning: "NOT_RUNNING",
|
|
637
|
+
Error: "ERROR"
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
|
|
524
642
|
// src/utils/util.ts
|
|
525
643
|
function getRandomId() {
|
|
526
644
|
return (0, import_crypto.randomUUID)();
|
|
@@ -587,14 +705,15 @@ var init_fileServerMessages = __esm({
|
|
|
587
705
|
var TunnelManager_exports = {};
|
|
588
706
|
__export(TunnelManager_exports, {
|
|
589
707
|
TunnelAlreadyRunningError: () => TunnelAlreadyRunningError,
|
|
590
|
-
TunnelManager: () => TunnelManager
|
|
708
|
+
TunnelManager: () => TunnelManager,
|
|
709
|
+
TunnelRebuildInProgressError: () => TunnelRebuildInProgressError
|
|
591
710
|
});
|
|
592
711
|
function mapToSdkLogLevel(level) {
|
|
593
712
|
if (level === "debug") return import_pinggy2.LogLevel.DEBUG;
|
|
594
713
|
if (level === "error") return import_pinggy2.LogLevel.ERROR;
|
|
595
714
|
return import_pinggy2.LogLevel.INFO;
|
|
596
715
|
}
|
|
597
|
-
var import_pinggy2, import_node_path, import_node_worker_threads, import_node_url, __filename3, __dirname2, STATS_HISTORY_LIMIT, TunnelAlreadyRunningError, TunnelManager;
|
|
716
|
+
var import_pinggy2, import_node_path, import_node_worker_threads, import_node_url, __filename3, __dirname2, START_FAILED_MESSAGE, STATS_HISTORY_LIMIT, TunnelAlreadyRunningError, TunnelRebuildInProgressError, TunnelManager;
|
|
598
717
|
var init_TunnelManager = __esm({
|
|
599
718
|
"src/tunnel_manager/TunnelManager.ts"() {
|
|
600
719
|
"use strict";
|
|
@@ -603,6 +722,7 @@ var init_TunnelManager = __esm({
|
|
|
603
722
|
init_logger();
|
|
604
723
|
init_tunnelLogger();
|
|
605
724
|
init_rotateLog();
|
|
725
|
+
init_types();
|
|
606
726
|
import_node_path = __toESM(require("path"), 1);
|
|
607
727
|
import_node_worker_threads = require("worker_threads");
|
|
608
728
|
import_node_url = require("url");
|
|
@@ -612,6 +732,7 @@ var init_TunnelManager = __esm({
|
|
|
612
732
|
init_fileServerMessages();
|
|
613
733
|
__filename3 = (0, import_node_url.fileURLToPath)(importMetaUrl);
|
|
614
734
|
__dirname2 = import_node_path.default.dirname(__filename3);
|
|
735
|
+
START_FAILED_MESSAGE = "Failed to start tunnel";
|
|
615
736
|
STATS_HISTORY_LIMIT = 100;
|
|
616
737
|
TunnelAlreadyRunningError = class extends Error {
|
|
617
738
|
constructor(configId, existingTunnelId) {
|
|
@@ -621,6 +742,13 @@ var init_TunnelManager = __esm({
|
|
|
621
742
|
this.name = "TunnelAlreadyRunningError";
|
|
622
743
|
}
|
|
623
744
|
};
|
|
745
|
+
TunnelRebuildInProgressError = class extends Error {
|
|
746
|
+
constructor(tunnelId) {
|
|
747
|
+
super(`Tunnel "${tunnelId}" is already restarting`);
|
|
748
|
+
this.tunnelId = tunnelId;
|
|
749
|
+
this.name = "TunnelRebuildInProgressError";
|
|
750
|
+
}
|
|
751
|
+
};
|
|
624
752
|
TunnelManager = class _TunnelManager {
|
|
625
753
|
constructor() {
|
|
626
754
|
this.tunnelsByTunnelId = /* @__PURE__ */ new Map();
|
|
@@ -665,7 +793,7 @@ var init_TunnelManager = __esm({
|
|
|
665
793
|
throw new Error("configId is required and must be a non-empty string");
|
|
666
794
|
}
|
|
667
795
|
const existing = this.tunnelsByConfigId.get(configId);
|
|
668
|
-
if (existing && !existing.isStopped) {
|
|
796
|
+
if (existing && (!existing.isStopped || existing.rebuild)) {
|
|
669
797
|
throw new TunnelAlreadyRunningError(configId, existing.tunnelid);
|
|
670
798
|
}
|
|
671
799
|
return this._createTunnelWithProcessedConfig({
|
|
@@ -753,11 +881,12 @@ var init_TunnelManager = __esm({
|
|
|
753
881
|
try {
|
|
754
882
|
urls = await managed.instance.start();
|
|
755
883
|
} catch (error) {
|
|
756
|
-
|
|
884
|
+
const reason = errorMessage(error) || START_FAILED_MESSAGE;
|
|
885
|
+
logger.warn("Failed to start tunnel", { tunnelId, error: reason });
|
|
757
886
|
managed.isStopped = true;
|
|
758
887
|
managed.stoppedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
759
888
|
managed.lastError = {
|
|
760
|
-
message:
|
|
889
|
+
message: reason,
|
|
761
890
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
762
891
|
isFatal: true
|
|
763
892
|
};
|
|
@@ -928,6 +1057,9 @@ var init_TunnelManager = __esm({
|
|
|
928
1057
|
logger.error(`Tunnel "${tunnelId}" not found when fetching status`);
|
|
929
1058
|
throw new Error(`Tunnel "${tunnelId}" not found`);
|
|
930
1059
|
}
|
|
1060
|
+
if (managed.rebuild?.startsAfter) {
|
|
1061
|
+
return "starting" /* Starting */;
|
|
1062
|
+
}
|
|
931
1063
|
if (managed.isStopped) {
|
|
932
1064
|
return "exited";
|
|
933
1065
|
}
|
|
@@ -978,6 +1110,10 @@ var init_TunnelManager = __esm({
|
|
|
978
1110
|
logger.warn("Attempted to remove tunnel that is not stopped", { tunnelId });
|
|
979
1111
|
return false;
|
|
980
1112
|
}
|
|
1113
|
+
if (managed.rebuild) {
|
|
1114
|
+
logger.warn("Attempted to remove tunnel that is restarting", { tunnelId });
|
|
1115
|
+
return false;
|
|
1116
|
+
}
|
|
981
1117
|
this._cleanupTunnelRecords(managed);
|
|
982
1118
|
logger.info("Removed stopped tunnel records", { tunnelId, configId: managed.configId });
|
|
983
1119
|
return true;
|
|
@@ -1057,17 +1193,27 @@ var init_TunnelManager = __esm({
|
|
|
1057
1193
|
if (!managed) {
|
|
1058
1194
|
return Promise.reject(new Error(`Tunnel with configId "${configId}" not found`));
|
|
1059
1195
|
}
|
|
1060
|
-
return
|
|
1196
|
+
return this.currentConfig(managed);
|
|
1061
1197
|
}
|
|
1062
1198
|
if (tunnelId) {
|
|
1063
1199
|
const managed = this.tunnelsByTunnelId.get(tunnelId);
|
|
1064
1200
|
if (!managed) {
|
|
1065
1201
|
return Promise.reject(new Error(`Tunnel with tunnelId "${tunnelId}" not found`));
|
|
1066
1202
|
}
|
|
1067
|
-
return
|
|
1203
|
+
return this.currentConfig(managed);
|
|
1068
1204
|
}
|
|
1069
1205
|
return Promise.reject(new Error(`Either configId or tunnelId must be provided`));
|
|
1070
1206
|
}
|
|
1207
|
+
/**
|
|
1208
|
+
* The instance's config, asked of its worker. A stopped instance has let its worker go, so a stopped
|
|
1209
|
+
* tunnel, including one a restart is replacing, answers with the config it was built from.
|
|
1210
|
+
*/
|
|
1211
|
+
currentConfig(managed) {
|
|
1212
|
+
if (managed.isStopped) {
|
|
1213
|
+
return Promise.resolve(managed.tunnelConfig);
|
|
1214
|
+
}
|
|
1215
|
+
return Promise.resolve(managed.instance.getConfig());
|
|
1216
|
+
}
|
|
1071
1217
|
/**
|
|
1072
1218
|
* Stop a tunnel instance and wait for the stop to settle, capped by a
|
|
1073
1219
|
* timeout. Failure is logged, not thrown: the caller replaces the
|
|
@@ -1091,6 +1237,46 @@ var init_TunnelManager = __esm({
|
|
|
1091
1237
|
if (timer) clearTimeout(timer);
|
|
1092
1238
|
}
|
|
1093
1239
|
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Marks the entry a restart or config update replaces. It stays in both maps until
|
|
1242
|
+
* `_createTunnelWithProcessedConfig` sets the new entry under the same ids. When the new entry
|
|
1243
|
+
* starts, the entry reports `starting`, and an error from an earlier run no longer describes it.
|
|
1244
|
+
*/
|
|
1245
|
+
beginRebuild(managed, startsAfter) {
|
|
1246
|
+
if (managed.rebuild) {
|
|
1247
|
+
throw new TunnelRebuildInProgressError(managed.tunnelid);
|
|
1248
|
+
}
|
|
1249
|
+
managed.rebuild = { startsAfter };
|
|
1250
|
+
if (startsAfter) {
|
|
1251
|
+
managed.lastError = {};
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
/** Stops the instance being replaced, and marks it stopped so no reader calls it again. */
|
|
1255
|
+
async stopForRebuild(managed) {
|
|
1256
|
+
if (managed.isStopped) {
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
await this.stopInstanceGuarded(managed);
|
|
1260
|
+
managed.isStopped = true;
|
|
1261
|
+
managed.stoppedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Ends a failed rebuild. When the old entry is still in the maps, it stays listed as stopped with
|
|
1265
|
+
* the reason. A new entry that failed to start already holds its own error.
|
|
1266
|
+
*/
|
|
1267
|
+
failRebuild(managed, error) {
|
|
1268
|
+
managed.rebuild = void 0;
|
|
1269
|
+
if (this.tunnelsByTunnelId.get(managed.tunnelid) !== managed) {
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
managed.isStopped = true;
|
|
1273
|
+
managed.stoppedAt = managed.stoppedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1274
|
+
managed.lastError = {
|
|
1275
|
+
message: errorMessage(error) || START_FAILED_MESSAGE,
|
|
1276
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1277
|
+
isFatal: true
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1094
1280
|
/**
|
|
1095
1281
|
* Restarts a tunnel with its current configuration.
|
|
1096
1282
|
* This function will stop the tunnel if it's running and start it again.
|
|
@@ -1101,6 +1287,7 @@ var init_TunnelManager = __esm({
|
|
|
1101
1287
|
if (!existingTunnel) {
|
|
1102
1288
|
throw new Error(`Tunnel "${tunnelid}" not found`);
|
|
1103
1289
|
}
|
|
1290
|
+
this.beginRebuild(existingTunnel, true);
|
|
1104
1291
|
logger.info("Initiating tunnel restart", {
|
|
1105
1292
|
tunnelId: tunnelid,
|
|
1106
1293
|
configId: existingTunnel.configId
|
|
@@ -1112,11 +1299,7 @@ var init_TunnelManager = __esm({
|
|
|
1112
1299
|
const currentServe = existingTunnel.serve;
|
|
1113
1300
|
const autoReconnect = existingTunnel.autoReconnect || false;
|
|
1114
1301
|
const currentOrigin = existingTunnel.origin;
|
|
1115
|
-
|
|
1116
|
-
await this.stopInstanceGuarded(existingTunnel);
|
|
1117
|
-
}
|
|
1118
|
-
this.tunnelsByTunnelId.delete(tunnelid);
|
|
1119
|
-
this.tunnelsByConfigId.delete(existingTunnel.configId);
|
|
1302
|
+
await this.stopForRebuild(existingTunnel);
|
|
1120
1303
|
this.tunnelStats.delete(tunnelid);
|
|
1121
1304
|
this.tunnelStatsListeners.delete(tunnelid);
|
|
1122
1305
|
this.tunnelErrorListeners.delete(tunnelid);
|
|
@@ -1142,6 +1325,7 @@ var init_TunnelManager = __esm({
|
|
|
1142
1325
|
}
|
|
1143
1326
|
await this.startTunnel(newTunnel.tunnelid);
|
|
1144
1327
|
} catch (error) {
|
|
1328
|
+
this.failRebuild(existingTunnel, error);
|
|
1145
1329
|
logger.error("Failed to restart tunnel", {
|
|
1146
1330
|
tunnelid,
|
|
1147
1331
|
error: error instanceof Error ? error.message : "Unknown error"
|
|
@@ -1199,12 +1383,9 @@ var init_TunnelManager = __esm({
|
|
|
1199
1383
|
const currentStartedAt = existingTunnel.startedAt;
|
|
1200
1384
|
const currentStoppedAt = existingTunnel.stoppedAt;
|
|
1201
1385
|
const requestedServe = this.resolveServePath(newConfig);
|
|
1386
|
+
this.beginRebuild(existingTunnel, !isStopped);
|
|
1202
1387
|
try {
|
|
1203
|
-
|
|
1204
|
-
await this.stopInstanceGuarded(existingTunnel);
|
|
1205
|
-
}
|
|
1206
|
-
this.tunnelsByTunnelId.delete(currentTunnelId);
|
|
1207
|
-
this.tunnelsByConfigId.delete(currentTunnelConfigId);
|
|
1388
|
+
await this.stopForRebuild(existingTunnel);
|
|
1208
1389
|
const mergedBaseConfig = {
|
|
1209
1390
|
...newConfig,
|
|
1210
1391
|
configId,
|
|
@@ -1265,6 +1446,7 @@ var init_TunnelManager = __esm({
|
|
|
1265
1446
|
error: errorMessage(restoreError)
|
|
1266
1447
|
});
|
|
1267
1448
|
}
|
|
1449
|
+
this.failRebuild(existingTunnel, error);
|
|
1268
1450
|
throw error;
|
|
1269
1451
|
}
|
|
1270
1452
|
}
|
|
@@ -2075,120 +2257,6 @@ var init_TunnelManager = __esm({
|
|
|
2075
2257
|
}
|
|
2076
2258
|
});
|
|
2077
2259
|
|
|
2078
|
-
// src/types.ts
|
|
2079
|
-
function isErrorResponse(obj) {
|
|
2080
|
-
return typeof obj === "object" && obj !== null && "code" in obj && "message" in obj && typeof obj.message === "string" && Object.values(ErrorCode).includes(obj.code);
|
|
2081
|
-
}
|
|
2082
|
-
function newErrorResponse(codeOrError, message) {
|
|
2083
|
-
if (typeof codeOrError === "object") {
|
|
2084
|
-
return codeOrError;
|
|
2085
|
-
}
|
|
2086
|
-
return {
|
|
2087
|
-
code: codeOrError,
|
|
2088
|
-
message
|
|
2089
|
-
};
|
|
2090
|
-
}
|
|
2091
|
-
function NewResponseObject(data) {
|
|
2092
|
-
const encoder = new TextEncoder();
|
|
2093
|
-
const bytes = encoder.encode(JSON.stringify(data));
|
|
2094
|
-
return {
|
|
2095
|
-
response: bytes,
|
|
2096
|
-
requestid: "",
|
|
2097
|
-
command: "",
|
|
2098
|
-
error: false,
|
|
2099
|
-
errorresponse: {}
|
|
2100
|
-
};
|
|
2101
|
-
}
|
|
2102
|
-
function NewErrorResponseObject(errorResponse) {
|
|
2103
|
-
return {
|
|
2104
|
-
response: new Uint8Array(),
|
|
2105
|
-
requestid: "",
|
|
2106
|
-
command: "",
|
|
2107
|
-
error: true,
|
|
2108
|
-
errorresponse: errorResponse
|
|
2109
|
-
};
|
|
2110
|
-
}
|
|
2111
|
-
function newStatus(tunnelState, errorCode, errorMsg) {
|
|
2112
|
-
let assignedState = tunnelState;
|
|
2113
|
-
if (tunnelState === "live" /* Live */) {
|
|
2114
|
-
assignedState = "running" /* Running */;
|
|
2115
|
-
} else if (tunnelState === "idle" /* New */) {
|
|
2116
|
-
assignedState = "idle" /* New */;
|
|
2117
|
-
} else if (tunnelState === "closed" /* Closed */) {
|
|
2118
|
-
assignedState = "exited" /* Exited */;
|
|
2119
|
-
}
|
|
2120
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2121
|
-
return {
|
|
2122
|
-
state: assignedState,
|
|
2123
|
-
errorcode: errorCode,
|
|
2124
|
-
errormsg: errorMsg,
|
|
2125
|
-
createdtimestamp: now,
|
|
2126
|
-
starttimestamp: now,
|
|
2127
|
-
endtimestamp: now,
|
|
2128
|
-
warnings: []
|
|
2129
|
-
};
|
|
2130
|
-
}
|
|
2131
|
-
function newStats() {
|
|
2132
|
-
return {
|
|
2133
|
-
numLiveConnections: 0,
|
|
2134
|
-
numTotalConnections: 0,
|
|
2135
|
-
numTotalReqBytes: 0,
|
|
2136
|
-
numTotalResBytes: 0,
|
|
2137
|
-
numTotalTxBytes: 0,
|
|
2138
|
-
elapsedTime: 0
|
|
2139
|
-
};
|
|
2140
|
-
}
|
|
2141
|
-
var TunnelStateType, TunnelErrorCodeType, TunnelWarningCode, ErrorCode, RemoteManagementStatus;
|
|
2142
|
-
var init_types = __esm({
|
|
2143
|
-
"src/types.ts"() {
|
|
2144
|
-
"use strict";
|
|
2145
|
-
init_cjs_shims();
|
|
2146
|
-
TunnelStateType = /* @__PURE__ */ ((TunnelStateType2) => {
|
|
2147
|
-
TunnelStateType2["New"] = "idle";
|
|
2148
|
-
TunnelStateType2["Starting"] = "starting";
|
|
2149
|
-
TunnelStateType2["Running"] = "running";
|
|
2150
|
-
TunnelStateType2["Live"] = "live";
|
|
2151
|
-
TunnelStateType2["Closed"] = "closed";
|
|
2152
|
-
TunnelStateType2["Exited"] = "exited";
|
|
2153
|
-
return TunnelStateType2;
|
|
2154
|
-
})(TunnelStateType || {});
|
|
2155
|
-
TunnelErrorCodeType = /* @__PURE__ */ ((TunnelErrorCodeType2) => {
|
|
2156
|
-
TunnelErrorCodeType2["NonResponsive"] = "non_responsive";
|
|
2157
|
-
TunnelErrorCodeType2["FailedToConnect"] = "failed_to_connect";
|
|
2158
|
-
TunnelErrorCodeType2["ErrorInAdditionalForwarding"] = "additional_forwarding_error";
|
|
2159
|
-
TunnelErrorCodeType2["WebdebuggerError"] = "webdebugger_error";
|
|
2160
|
-
TunnelErrorCodeType2["NoError"] = "";
|
|
2161
|
-
return TunnelErrorCodeType2;
|
|
2162
|
-
})(TunnelErrorCodeType || {});
|
|
2163
|
-
TunnelWarningCode = /* @__PURE__ */ ((TunnelWarningCode2) => {
|
|
2164
|
-
TunnelWarningCode2["InvalidTunnelServePath"] = "INVALID_TUNNEL_SERVE_PATH";
|
|
2165
|
-
TunnelWarningCode2["UnknownWarning"] = "UNKNOWN_WARNING";
|
|
2166
|
-
return TunnelWarningCode2;
|
|
2167
|
-
})(TunnelWarningCode || {});
|
|
2168
|
-
ErrorCode = {
|
|
2169
|
-
InvalidRequestMethodError: "INVALID_REQUEST_METHOD",
|
|
2170
|
-
InvalidRequestBodyError: "COULD_NOT_READ_BODY",
|
|
2171
|
-
InternalServerError: "INTERNAL_SERVER_ERROR",
|
|
2172
|
-
InvalidBodyFormatError: "INVALID_DATA_FORMAT",
|
|
2173
|
-
ErrorStartingTunnel: "ERROR_STARTING_TUNNEL",
|
|
2174
|
-
TunnelNotFound: "TUNNEL_WITH_ID_OR_CONFIG_ID_NOT_FOUND",
|
|
2175
|
-
TunnelAlreadyRunningError: "TUNNEL_WITH_ID_OR_CONFIG_ID_ALREADY_RUNNING",
|
|
2176
|
-
WebsocketUpgradeFailError: "WEBSOCKET_UPGRADE_FAILED",
|
|
2177
|
-
RemoteManagementAlreadyRunning: "REMOTE_MANAGEMENT_ALREADY_RUNNING",
|
|
2178
|
-
RemoteManagementNotRunning: "REMOTE_MANAGEMENT_NOT_RUNNING",
|
|
2179
|
-
RemoteManagementDeserializationFailed: "REMOTE_MANAGEMENT_DESERIALIZATION_FAILED"
|
|
2180
|
-
};
|
|
2181
|
-
RemoteManagementStatus = {
|
|
2182
|
-
Connecting: "CONNECTING",
|
|
2183
|
-
Disconnecting: "DISCONNECTING",
|
|
2184
|
-
Reconnecting: "RECONNECTING",
|
|
2185
|
-
Running: "RUNNING",
|
|
2186
|
-
NotRunning: "NOT_RUNNING",
|
|
2187
|
-
Error: "ERROR"
|
|
2188
|
-
};
|
|
2189
|
-
}
|
|
2190
|
-
});
|
|
2191
|
-
|
|
2192
2260
|
// src/remote_management/remote_schema.ts
|
|
2193
2261
|
function pinggyOptionsToTunnelConfigV1(opts, configStoredInCli) {
|
|
2194
2262
|
const parsedTokens = opts.bearerTokenAuth ? Array.isArray(opts.bearerTokenAuth) ? opts.bearerTokenAuth : JSON.parse(opts.bearerTokenAuth) : [];
|
|
@@ -2505,6 +2573,9 @@ var init_handler = __esm({
|
|
|
2505
2573
|
}
|
|
2506
2574
|
if (managed?.lastError) {
|
|
2507
2575
|
status.lastError = managed.lastError;
|
|
2576
|
+
if (managed.lastError.isFatal) {
|
|
2577
|
+
status.errormsg = managed.lastError.message;
|
|
2578
|
+
}
|
|
2508
2579
|
}
|
|
2509
2580
|
} catch (e) {
|
|
2510
2581
|
}
|
|
@@ -2569,6 +2640,14 @@ var init_handler = __esm({
|
|
|
2569
2640
|
message: err instanceof Error ? err.message : fallback
|
|
2570
2641
|
});
|
|
2571
2642
|
}
|
|
2643
|
+
/**
|
|
2644
|
+
* A restart or update of a tunnel another one is still rebuilding. A no-wait call checks this before
|
|
2645
|
+
* it answers, because a throw from the call it does not await is only logged.
|
|
2646
|
+
*/
|
|
2647
|
+
rebuildInProgress(tunnelid) {
|
|
2648
|
+
const err = new TunnelRebuildInProgressError(tunnelid);
|
|
2649
|
+
return this.error(ErrorCode.TunnelAlreadyRunningError, err, err.message);
|
|
2650
|
+
}
|
|
2572
2651
|
// --- Operations ---
|
|
2573
2652
|
async handleStart(config, noWait = false, origin = "cli") {
|
|
2574
2653
|
try {
|
|
@@ -2631,6 +2710,7 @@ var init_handler = __esm({
|
|
|
2631
2710
|
if (noWait) {
|
|
2632
2711
|
const existing = this.tunnelManager.getManagedTunnel(config.configid);
|
|
2633
2712
|
if (!existing.tunnelConfig) throw new Error("Invalid tunnel state before configuration update");
|
|
2713
|
+
if (existing.rebuild) return this.rebuildInProgress(existing.tunnelid);
|
|
2634
2714
|
if (!existing.isStopped) {
|
|
2635
2715
|
this.tunnelManager.updateConfig(updateOpts).catch((err) => {
|
|
2636
2716
|
logger.error("No-wait updateConfig failed", { configid: config.configid, err: String(err) });
|
|
@@ -2651,6 +2731,7 @@ var init_handler = __esm({
|
|
|
2651
2731
|
if (noWait) {
|
|
2652
2732
|
const existing = this.tunnelManager.getManagedTunnel(config.configId);
|
|
2653
2733
|
if (!existing.tunnelConfig) throw new Error("Invalid tunnel state before configuration update");
|
|
2734
|
+
if (existing.rebuild) return this.rebuildInProgress(existing.tunnelid);
|
|
2654
2735
|
if (!existing.isStopped) {
|
|
2655
2736
|
this.tunnelManager.updateConfig(config).catch((err) => {
|
|
2656
2737
|
logger.error("No-wait updateConfigV2 failed", { configId: config.configId, err: String(err) });
|
|
@@ -2749,6 +2830,7 @@ var init_handler = __esm({
|
|
|
2749
2830
|
if (noWait) {
|
|
2750
2831
|
const managed2 = this.tunnelManager.getManagedTunnel("", tunnelid);
|
|
2751
2832
|
if (!managed2?.tunnelConfig) throw new Error(`Tunnel config for ID "${tunnelid}" not found`);
|
|
2833
|
+
if (managed2.rebuild) return this.rebuildInProgress(tunnelid);
|
|
2752
2834
|
this.tunnelManager.restartTunnel(tunnelid).catch((err) => {
|
|
2753
2835
|
logger.error("No-wait restartTunnel failed", { tunnelid, err: String(err) });
|
|
2754
2836
|
});
|
|
@@ -2759,6 +2841,9 @@ var init_handler = __esm({
|
|
|
2759
2841
|
if (!managed?.tunnelConfig) throw new Error(`Tunnel config for ID "${tunnelid}" not found`);
|
|
2760
2842
|
return this.buildTunnelResponse(tunnelid, managed.tunnelConfig, managed.configId, managed.tunnelName, managed.serve);
|
|
2761
2843
|
} catch (err) {
|
|
2844
|
+
if (err instanceof TunnelRebuildInProgressError) {
|
|
2845
|
+
return this.rebuildInProgress(tunnelid);
|
|
2846
|
+
}
|
|
2762
2847
|
return this.error(ErrorCode.TunnelNotFound, err, "Failed to restart tunnel");
|
|
2763
2848
|
}
|
|
2764
2849
|
}
|
|
@@ -2801,7 +2886,7 @@ var init_handler = __esm({
|
|
|
2801
2886
|
});
|
|
2802
2887
|
|
|
2803
2888
|
// src/daemon/ipc/ipcRoutes.ts
|
|
2804
|
-
var IPC_VERSION, Route, DaemonHost, ShutdownStatus, SessionMode;
|
|
2889
|
+
var IPC_VERSION, Route, DaemonHost, ShutdownStatus, DeviceAgentState, SessionMode;
|
|
2805
2890
|
var init_ipcRoutes = __esm({
|
|
2806
2891
|
"src/daemon/ipc/ipcRoutes.ts"() {
|
|
2807
2892
|
"use strict";
|
|
@@ -2824,7 +2909,10 @@ var init_ipcRoutes = __esm({
|
|
|
2824
2909
|
SetLogLevel: "POST /loglevel",
|
|
2825
2910
|
GetTunnelLogging: "GET /config/tunnel-logging",
|
|
2826
2911
|
SetTunnelLogging: "POST /config/tunnel-logging",
|
|
2827
|
-
GetLogPaths: "GET /logs/paths"
|
|
2912
|
+
GetLogPaths: "GET /logs/paths",
|
|
2913
|
+
GetDeviceAgent: "GET /device",
|
|
2914
|
+
StartDeviceAgent: "POST /device/start",
|
|
2915
|
+
StopDeviceAgent: "POST /device/stop"
|
|
2828
2916
|
};
|
|
2829
2917
|
DaemonHost = {
|
|
2830
2918
|
CLI: "cli",
|
|
@@ -2834,6 +2922,12 @@ var init_ipcRoutes = __esm({
|
|
|
2834
2922
|
ShuttingDown: "shutting_down",
|
|
2835
2923
|
RefusedInApp: "refused_in_app"
|
|
2836
2924
|
};
|
|
2925
|
+
DeviceAgentState = {
|
|
2926
|
+
Stopped: "stopped",
|
|
2927
|
+
Connecting: "connecting",
|
|
2928
|
+
Connected: "connected",
|
|
2929
|
+
Reconnecting: "reconnecting"
|
|
2930
|
+
};
|
|
2837
2931
|
SessionMode = {
|
|
2838
2932
|
Foreground: "foreground",
|
|
2839
2933
|
Detached: "detached"
|
|
@@ -2844,9 +2938,11 @@ var init_ipcRoutes = __esm({
|
|
|
2844
2938
|
// src/daemon/ipc/ipcClient.ts
|
|
2845
2939
|
var ipcClient_exports = {};
|
|
2846
2940
|
__export(ipcClient_exports, {
|
|
2847
|
-
|
|
2941
|
+
HTTP_NOT_FOUND: () => HTTP_NOT_FOUND,
|
|
2942
|
+
IPCClient: () => IPCClient,
|
|
2943
|
+
IPCHttpError: () => IPCHttpError
|
|
2848
2944
|
});
|
|
2849
|
-
var import_node_http, REQUEST_TIMEOUT_MS, IPCClient;
|
|
2945
|
+
var import_node_http, REQUEST_TIMEOUT_MS, IPCHttpError, HTTP_NOT_FOUND, IPCClient;
|
|
2850
2946
|
var init_ipcClient = __esm({
|
|
2851
2947
|
"src/daemon/ipc/ipcClient.ts"() {
|
|
2852
2948
|
"use strict";
|
|
@@ -2854,6 +2950,14 @@ var init_ipcClient = __esm({
|
|
|
2854
2950
|
import_node_http = __toESM(require("http"), 1);
|
|
2855
2951
|
init_ipcRoutes();
|
|
2856
2952
|
REQUEST_TIMEOUT_MS = 1e4;
|
|
2953
|
+
IPCHttpError = class extends Error {
|
|
2954
|
+
constructor(statusCode, message) {
|
|
2955
|
+
super(message);
|
|
2956
|
+
this.statusCode = statusCode;
|
|
2957
|
+
this.name = "IPCHttpError";
|
|
2958
|
+
}
|
|
2959
|
+
};
|
|
2960
|
+
HTTP_NOT_FOUND = 404;
|
|
2857
2961
|
IPCClient = class {
|
|
2858
2962
|
constructor(port, origin = "cli") {
|
|
2859
2963
|
this.port = port;
|
|
@@ -2920,6 +3024,15 @@ var init_ipcClient = __esm({
|
|
|
2920
3024
|
async resolveLogPath(q) {
|
|
2921
3025
|
return this.request("GET", `/logs/resolve?q=${encodeURIComponent(q)}`);
|
|
2922
3026
|
}
|
|
3027
|
+
async getDeviceAgentStatus(timeoutMs) {
|
|
3028
|
+
return this.call(Route.GetDeviceAgent, void 0, timeoutMs);
|
|
3029
|
+
}
|
|
3030
|
+
async startDeviceAgent() {
|
|
3031
|
+
return this.call(Route.StartDeviceAgent, {});
|
|
3032
|
+
}
|
|
3033
|
+
async stopDeviceAgent() {
|
|
3034
|
+
return this.call(Route.StopDeviceAgent, {});
|
|
3035
|
+
}
|
|
2923
3036
|
/**
|
|
2924
3037
|
* Get the WebSocket URL for event streaming.
|
|
2925
3038
|
*/
|
|
@@ -2932,11 +3045,11 @@ var init_ipcClient = __esm({
|
|
|
2932
3045
|
call(key, body, timeoutMs) {
|
|
2933
3046
|
const spaceIdx = key.indexOf(" ");
|
|
2934
3047
|
const method = key.slice(0, spaceIdx);
|
|
2935
|
-
const
|
|
3048
|
+
const path12 = key.slice(spaceIdx + 1);
|
|
2936
3049
|
const payload = method === "GET" ? void 0 : JSON.stringify(body ?? {});
|
|
2937
|
-
return this.request(method,
|
|
3050
|
+
return this.request(method, path12, payload, timeoutMs);
|
|
2938
3051
|
}
|
|
2939
|
-
request(method,
|
|
3052
|
+
request(method, path12, body, timeoutMs) {
|
|
2940
3053
|
return new Promise((resolve, reject) => {
|
|
2941
3054
|
const headers = {
|
|
2942
3055
|
"X-Pinggy-Origin": this.origin
|
|
@@ -2949,7 +3062,7 @@ var init_ipcClient = __esm({
|
|
|
2949
3062
|
{
|
|
2950
3063
|
hostname: "127.0.0.1",
|
|
2951
3064
|
port: this.port,
|
|
2952
|
-
path:
|
|
3065
|
+
path: path12,
|
|
2953
3066
|
method,
|
|
2954
3067
|
headers,
|
|
2955
3068
|
timeout: timeoutMs ?? REQUEST_TIMEOUT_MS
|
|
@@ -2961,7 +3074,10 @@ var init_ipcClient = __esm({
|
|
|
2961
3074
|
const text = Buffer.concat(chunks).toString("utf-8");
|
|
2962
3075
|
const statusCode = res.statusCode ?? 0;
|
|
2963
3076
|
if (statusCode < 200 || statusCode >= 300) {
|
|
2964
|
-
reject(new
|
|
3077
|
+
reject(new IPCHttpError(
|
|
3078
|
+
statusCode,
|
|
3079
|
+
`Daemon returned HTTP ${statusCode}: ${text.slice(0, 200)}`
|
|
3080
|
+
));
|
|
2965
3081
|
return;
|
|
2966
3082
|
}
|
|
2967
3083
|
try {
|
|
@@ -3768,42 +3884,42 @@ var init_websocket_handlers = __esm({
|
|
|
3768
3884
|
const cmd = (req.command || "").toLowerCase();
|
|
3769
3885
|
const raw = this.safeParse(req.data);
|
|
3770
3886
|
try {
|
|
3771
|
-
let
|
|
3887
|
+
let response2;
|
|
3772
3888
|
switch (cmd) {
|
|
3773
3889
|
case WsCommand.Start: {
|
|
3774
|
-
|
|
3890
|
+
response2 = await this.handleStartReq(req, raw);
|
|
3775
3891
|
break;
|
|
3776
3892
|
}
|
|
3777
3893
|
case WsCommand.StartV2: {
|
|
3778
|
-
|
|
3894
|
+
response2 = await this.handleStartV2Req(req, raw);
|
|
3779
3895
|
break;
|
|
3780
3896
|
}
|
|
3781
3897
|
case WsCommand.Stop: {
|
|
3782
|
-
|
|
3898
|
+
response2 = await this.handleStopReq(req, raw);
|
|
3783
3899
|
break;
|
|
3784
3900
|
}
|
|
3785
3901
|
case WsCommand.Get: {
|
|
3786
|
-
|
|
3902
|
+
response2 = await this.handleGetReq(req, raw);
|
|
3787
3903
|
break;
|
|
3788
3904
|
}
|
|
3789
3905
|
case WsCommand.Restart: {
|
|
3790
|
-
|
|
3906
|
+
response2 = await this.handleRestartReq(req, raw);
|
|
3791
3907
|
break;
|
|
3792
3908
|
}
|
|
3793
3909
|
case WsCommand.UpdateConfig: {
|
|
3794
|
-
|
|
3910
|
+
response2 = await this.handleUpdateConfigReq(req, raw);
|
|
3795
3911
|
break;
|
|
3796
3912
|
}
|
|
3797
3913
|
case WsCommand.UpdateConfigV2: {
|
|
3798
|
-
|
|
3914
|
+
response2 = await this.handleUpdateConfigV2Req(req, raw);
|
|
3799
3915
|
break;
|
|
3800
3916
|
}
|
|
3801
3917
|
case WsCommand.List: {
|
|
3802
|
-
|
|
3918
|
+
response2 = await this.handleListReq(req);
|
|
3803
3919
|
break;
|
|
3804
3920
|
}
|
|
3805
3921
|
case WsCommand.ListV2: {
|
|
3806
|
-
|
|
3922
|
+
response2 = await this.handleListV2Req(req);
|
|
3807
3923
|
break;
|
|
3808
3924
|
}
|
|
3809
3925
|
case WsCommand.GetVersion: {
|
|
@@ -3816,8 +3932,8 @@ var init_websocket_handlers = __esm({
|
|
|
3816
3932
|
}
|
|
3817
3933
|
return this.sendError(ws, req, "Invalid command");
|
|
3818
3934
|
}
|
|
3819
|
-
logger.debug("Sending response", { command:
|
|
3820
|
-
this.sendResponse(ws,
|
|
3935
|
+
logger.debug("Sending response", { command: response2.command, requestid: response2.requestid });
|
|
3936
|
+
this.sendResponse(ws, response2);
|
|
3821
3937
|
} catch (e) {
|
|
3822
3938
|
if (e instanceof import_zod2.default.ZodError) {
|
|
3823
3939
|
logger.warn("Validation failed", { cmd, issues: e.issues });
|
|
@@ -4014,7 +4130,7 @@ function startRemoteManagement(remoteManagementConfig, tunnelHandler) {
|
|
|
4014
4130
|
resolve(getRemoteManagementState());
|
|
4015
4131
|
}
|
|
4016
4132
|
};
|
|
4017
|
-
const
|
|
4133
|
+
const runLoop2 = async () => {
|
|
4018
4134
|
const sigintHandler = () => {
|
|
4019
4135
|
_stopRequested = true;
|
|
4020
4136
|
};
|
|
@@ -4042,7 +4158,7 @@ function startRemoteManagement(remoteManagementConfig, tunnelHandler) {
|
|
|
4042
4158
|
process.removeListener("SIGINT", sigintHandler);
|
|
4043
4159
|
logger.info("Remote management stopped.");
|
|
4044
4160
|
};
|
|
4045
|
-
|
|
4161
|
+
runLoop2().catch((err) => settleOnce(err instanceof Error ? err : new Error(String(err))));
|
|
4046
4162
|
});
|
|
4047
4163
|
}
|
|
4048
4164
|
function getRemoteManagementState() {
|
|
@@ -4623,10 +4739,10 @@ function parseArgs2(finalConfig, remainingPositionals) {
|
|
|
4623
4739
|
}
|
|
4624
4740
|
function storeJson(config, saveconf) {
|
|
4625
4741
|
if (saveconf) {
|
|
4626
|
-
const
|
|
4742
|
+
const path12 = saveconf;
|
|
4627
4743
|
try {
|
|
4628
|
-
import_fs6.default.writeFileSync(
|
|
4629
|
-
logger.info(`Configuration saved to ${
|
|
4744
|
+
import_fs6.default.writeFileSync(path12, JSON.stringify(config, null, 2), { encoding: "utf-8", flag: "w" });
|
|
4745
|
+
logger.info(`Configuration saved to ${path12}`);
|
|
4630
4746
|
} catch (err) {
|
|
4631
4747
|
const msg = err instanceof Error ? err.message : String(err);
|
|
4632
4748
|
logger.error("Error loading configuration:", msg);
|
|
@@ -5135,7 +5251,11 @@ var init_configStore = __esm({
|
|
|
5135
5251
|
DaemonAlias: "d",
|
|
5136
5252
|
Logs: "logs",
|
|
5137
5253
|
Log: "log",
|
|
5138
|
-
Restart: "restart"
|
|
5254
|
+
Restart: "restart",
|
|
5255
|
+
Device: "device",
|
|
5256
|
+
// The old spelling, refused with a hint. As a subcommand it never reaches tunnel mode, where `--token` is the
|
|
5257
|
+
// tunnel token and a device token would go to the tunnel server.
|
|
5258
|
+
Devices: "devices"
|
|
5139
5259
|
};
|
|
5140
5260
|
ConfigVerb = {
|
|
5141
5261
|
List: "list",
|
|
@@ -6765,7 +6885,7 @@ async function printAlreadyRunning(client, configId, label) {
|
|
|
6765
6885
|
async function startTunnel(client, config, opts) {
|
|
6766
6886
|
const result = await client.handleStartV2(config, false, opts.mode);
|
|
6767
6887
|
const prefix = opts.label ? `[${opts.label}] ` : "";
|
|
6768
|
-
const
|
|
6888
|
+
const fail2 = (reason) => {
|
|
6769
6889
|
const msg = `${prefix}Failed to start tunnel: ${reason}`;
|
|
6770
6890
|
if (opts.onError === "fatal") {
|
|
6771
6891
|
printer_default.error(msg);
|
|
@@ -6780,11 +6900,11 @@ async function startTunnel(client, config, opts) {
|
|
|
6780
6900
|
await printAlreadyRunning(client, config.configId, opts.label);
|
|
6781
6901
|
return null;
|
|
6782
6902
|
}
|
|
6783
|
-
return
|
|
6903
|
+
return fail2(result.message);
|
|
6784
6904
|
}
|
|
6785
6905
|
const lastError = result.status?.lastError;
|
|
6786
6906
|
if (lastError?.isFatal) {
|
|
6787
|
-
return
|
|
6907
|
+
return fail2(lastError.message);
|
|
6788
6908
|
}
|
|
6789
6909
|
return result;
|
|
6790
6910
|
}
|
|
@@ -7410,106 +7530,3177 @@ function uninstallWindows() {
|
|
|
7410
7530
|
console.log("No scheduled task found to remove.");
|
|
7411
7531
|
}
|
|
7412
7532
|
}
|
|
7413
|
-
function installService() {
|
|
7414
|
-
const platform2 = import_node_os.default.platform();
|
|
7415
|
-
const bin = resolveBinary();
|
|
7416
|
-
if (!("pkg" in process) && bin.program !== process.execPath) {
|
|
7417
|
-
console.warn(
|
|
7418
|
-
"Warning: Using npm-installed binary. The path may change if Node.js is updated.\nConsider using a standalone binary (pkg) for system services."
|
|
7419
|
-
);
|
|
7533
|
+
function installService() {
|
|
7534
|
+
const platform2 = import_node_os.default.platform();
|
|
7535
|
+
const bin = resolveBinary();
|
|
7536
|
+
if (!("pkg" in process) && bin.program !== process.execPath) {
|
|
7537
|
+
console.warn(
|
|
7538
|
+
"Warning: Using npm-installed binary. The path may change if Node.js is updated.\nConsider using a standalone binary (pkg) for system services."
|
|
7539
|
+
);
|
|
7540
|
+
}
|
|
7541
|
+
switch (platform2) {
|
|
7542
|
+
case "linux":
|
|
7543
|
+
installSystemd();
|
|
7544
|
+
break;
|
|
7545
|
+
case "darwin":
|
|
7546
|
+
installLaunchd();
|
|
7547
|
+
break;
|
|
7548
|
+
case "win32":
|
|
7549
|
+
installWindows();
|
|
7550
|
+
break;
|
|
7551
|
+
default:
|
|
7552
|
+
console.error(`Unsupported platform: ${platform2}`);
|
|
7553
|
+
process.exit(1);
|
|
7554
|
+
}
|
|
7555
|
+
}
|
|
7556
|
+
function uninstallService() {
|
|
7557
|
+
const platform2 = import_node_os.default.platform();
|
|
7558
|
+
switch (platform2) {
|
|
7559
|
+
case "linux":
|
|
7560
|
+
uninstallSystemd();
|
|
7561
|
+
break;
|
|
7562
|
+
case "darwin":
|
|
7563
|
+
uninstallLaunchd();
|
|
7564
|
+
break;
|
|
7565
|
+
case "win32":
|
|
7566
|
+
uninstallWindows();
|
|
7567
|
+
break;
|
|
7568
|
+
default:
|
|
7569
|
+
console.error(`Unsupported platform: ${platform2}`);
|
|
7570
|
+
process.exit(1);
|
|
7571
|
+
}
|
|
7572
|
+
}
|
|
7573
|
+
var import_node_os, import_node_fs3, import_node_path2, import_node_child_process, SERVICE_LABEL, SYSTEMD_SERVICE_NAME, WINDOWS_TASK_NAME;
|
|
7574
|
+
var init_serviceInstaller = __esm({
|
|
7575
|
+
"src/daemon/lifecycle/serviceInstaller.ts"() {
|
|
7576
|
+
"use strict";
|
|
7577
|
+
init_cjs_shims();
|
|
7578
|
+
import_node_os = __toESM(require("os"), 1);
|
|
7579
|
+
import_node_fs3 = __toESM(require("fs"), 1);
|
|
7580
|
+
import_node_path2 = __toESM(require("path"), 1);
|
|
7581
|
+
import_node_child_process = require("child_process");
|
|
7582
|
+
init_logger();
|
|
7583
|
+
SERVICE_LABEL = "io.pinggy.agent";
|
|
7584
|
+
SYSTEMD_SERVICE_NAME = "pinggy";
|
|
7585
|
+
WINDOWS_TASK_NAME = "PinggyDaemon";
|
|
7586
|
+
}
|
|
7587
|
+
});
|
|
7588
|
+
|
|
7589
|
+
// src/utils/helpMessages.ts
|
|
7590
|
+
function printDaemonHelp() {
|
|
7591
|
+
console.log("\nUsage: pinggy daemon <command>");
|
|
7592
|
+
console.log(" pinggy d <command>\n");
|
|
7593
|
+
console.log("Commands:");
|
|
7594
|
+
console.log(" start Start the daemon process");
|
|
7595
|
+
console.log(" stop Stop the daemon (stops all tunnels)");
|
|
7596
|
+
console.log(" status Show daemon PID and uptime");
|
|
7597
|
+
console.log("Tunnel operations:");
|
|
7598
|
+
console.log(" pinggy ps List running tunnels");
|
|
7599
|
+
console.log(" pinggy stop <name|id> Stop a specific tunnel");
|
|
7600
|
+
console.log(" pinggy attach <name|id> Re-attach TUI to a tunnel\n");
|
|
7601
|
+
}
|
|
7602
|
+
function printLogHelp() {
|
|
7603
|
+
console.log("\nUsage: pinggy log <verb> [options]\n");
|
|
7604
|
+
console.log("Commands:");
|
|
7605
|
+
console.log(" level Print current log level");
|
|
7606
|
+
console.log(" level debug|info|error Set log level");
|
|
7607
|
+
console.log(" path Print daemon log path");
|
|
7608
|
+
console.log(" path <name|id> Print tunnel log path\n");
|
|
7609
|
+
}
|
|
7610
|
+
function printConfigHelp() {
|
|
7611
|
+
console.log("\nUsage: pinggy config <command> [name] [options]\n");
|
|
7612
|
+
console.log("Commands:");
|
|
7613
|
+
console.log(" list List all saved configs");
|
|
7614
|
+
console.log(" show <name> Show config details");
|
|
7615
|
+
console.log(" save <name> [tunnel flags] Save a tunnel config");
|
|
7616
|
+
console.log(" update <name> [tunnel flags] Update a saved config");
|
|
7617
|
+
console.log(" delete <name> Delete a saved config");
|
|
7618
|
+
console.log(" auto <name> Enable auto-start");
|
|
7619
|
+
console.log(" noauto <name> Disable auto-start\n");
|
|
7620
|
+
}
|
|
7621
|
+
function printStartHelp() {
|
|
7622
|
+
console.log("\nUsage: pinggy start <name> [options]\n");
|
|
7623
|
+
console.log("Examples:");
|
|
7624
|
+
console.log(" pinggy start my-tunnel Start a saved tunnel");
|
|
7625
|
+
console.log(" pinggy start my-tunnel -l 4000 Start with override");
|
|
7626
|
+
console.log(" pinggy start tunnela tunnelb Start multiple tunnels");
|
|
7627
|
+
console.log(" pinggy start --all Start all auto-start tunnels\n");
|
|
7628
|
+
}
|
|
7629
|
+
function printDeviceHelp() {
|
|
7630
|
+
console.log("\nUsage: pinggy device <command> [options]\n");
|
|
7631
|
+
console.log("Run this machine as a Pinggy device.\n");
|
|
7632
|
+
console.log("Commands:");
|
|
7633
|
+
console.log(" login Sign this machine in with your email, then stay connected");
|
|
7634
|
+
console.log(" connect --token <TOKEN> Enrol, then stay connected until interrupted");
|
|
7635
|
+
console.log(" stop Stop the agent running in the background");
|
|
7636
|
+
console.log(" status Show the enrolment, and the agent running in the background");
|
|
7637
|
+
console.log(" remove Forget the local credential (does not delete the device)\n");
|
|
7638
|
+
console.log("Options:");
|
|
7639
|
+
console.log(" -b login and connect: run the agent in the background, in the daemon.");
|
|
7640
|
+
console.log(" It stays connected after this terminal closes");
|
|
7641
|
+
console.log(" --token <TOKEN> Device token from the dashboard");
|
|
7642
|
+
console.log(" --manage <HOST> Dashboard address. Default: dashboard.pinggy.io");
|
|
7643
|
+
console.log(" --name <NAME> login only: the device's name. Default: this machine's hostname\n");
|
|
7644
|
+
console.log("Environment:");
|
|
7645
|
+
console.log(" DEVICE_AGENT_TOKEN connect only: the device token, when --token is not given. With no");
|
|
7646
|
+
console.log(" --manage, it dials dashboard.pinggy.io. Removed once read\n");
|
|
7647
|
+
}
|
|
7648
|
+
var init_helpMessages = __esm({
|
|
7649
|
+
"src/utils/helpMessages.ts"() {
|
|
7650
|
+
"use strict";
|
|
7651
|
+
init_cjs_shims();
|
|
7652
|
+
}
|
|
7653
|
+
});
|
|
7654
|
+
|
|
7655
|
+
// src/devices/deviceIdentity.ts
|
|
7656
|
+
function readDeviceIdentity() {
|
|
7657
|
+
const filePath = getDeviceConfigPath();
|
|
7658
|
+
if (!import_fs8.default.existsSync(filePath)) {
|
|
7659
|
+
return null;
|
|
7660
|
+
}
|
|
7661
|
+
try {
|
|
7662
|
+
return JSON.parse(import_fs8.default.readFileSync(filePath, "utf-8"));
|
|
7663
|
+
} catch (e) {
|
|
7664
|
+
logger.warn("Could not read device identity file", { error: String(e) });
|
|
7665
|
+
return null;
|
|
7666
|
+
}
|
|
7667
|
+
}
|
|
7668
|
+
function writeDeviceIdentity(identity) {
|
|
7669
|
+
ensurePinggyConfigDir();
|
|
7670
|
+
const filePath = getDeviceConfigPath();
|
|
7671
|
+
import_fs8.default.writeFileSync(filePath, JSON.stringify(identity, null, 2), {
|
|
7672
|
+
encoding: "utf-8",
|
|
7673
|
+
mode: SECRET_FILE_MODE
|
|
7674
|
+
});
|
|
7675
|
+
import_fs8.default.chmodSync(filePath, SECRET_FILE_MODE);
|
|
7676
|
+
}
|
|
7677
|
+
function clearDeviceIdentity() {
|
|
7678
|
+
const filePath = getDeviceConfigPath();
|
|
7679
|
+
if (!import_fs8.default.existsSync(filePath)) {
|
|
7680
|
+
return false;
|
|
7681
|
+
}
|
|
7682
|
+
import_fs8.default.unlinkSync(filePath);
|
|
7683
|
+
return true;
|
|
7684
|
+
}
|
|
7685
|
+
function maskToken(token) {
|
|
7686
|
+
return token.length <= 8 ? "***" : `${token.slice(0, 8)}...${token.slice(-4)}`;
|
|
7687
|
+
}
|
|
7688
|
+
var import_fs8, SECRET_FILE_MODE;
|
|
7689
|
+
var init_deviceIdentity = __esm({
|
|
7690
|
+
"src/devices/deviceIdentity.ts"() {
|
|
7691
|
+
"use strict";
|
|
7692
|
+
init_cjs_shims();
|
|
7693
|
+
import_fs8 = __toESM(require("fs"), 1);
|
|
7694
|
+
init_configDir();
|
|
7695
|
+
init_logger();
|
|
7696
|
+
SECRET_FILE_MODE = 384;
|
|
7697
|
+
}
|
|
7698
|
+
});
|
|
7699
|
+
|
|
7700
|
+
// src/devices/envelope.ts
|
|
7701
|
+
function nowSeconds() {
|
|
7702
|
+
return Math.floor(Date.now() / 1e3);
|
|
7703
|
+
}
|
|
7704
|
+
function request(ch, op, payload) {
|
|
7705
|
+
return { v: PROTOCOL_VERSION, kind: "req", ch, op, id: (0, import_crypto2.randomUUID)(), seq: 0, ts: nowSeconds(), payload };
|
|
7706
|
+
}
|
|
7707
|
+
function response(to, op, payload) {
|
|
7708
|
+
return { v: PROTOCOL_VERSION, kind: "res", ch: to.ch, op, id: to.id, seq: 0, ts: nowSeconds(), payload };
|
|
7709
|
+
}
|
|
7710
|
+
function event(ch, op, payload) {
|
|
7711
|
+
return { v: PROTOCOL_VERSION, kind: "event", ch, op, id: "", seq: 0, ts: nowSeconds(), payload };
|
|
7712
|
+
}
|
|
7713
|
+
function parseEnvelope(raw) {
|
|
7714
|
+
try {
|
|
7715
|
+
const parsed = JSON.parse(raw);
|
|
7716
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
7717
|
+
if (typeof parsed.ch !== "string" || typeof parsed.op !== "string") return null;
|
|
7718
|
+
return parsed;
|
|
7719
|
+
} catch {
|
|
7720
|
+
return null;
|
|
7721
|
+
}
|
|
7722
|
+
}
|
|
7723
|
+
var import_crypto2, PROTOCOL_VERSION, CHANNEL_SYSTEM, CHANNEL_DEVICE, OP_HELLO, OP_WELCOME, OP_HEARTBEAT, OP_DISCONNECT, OP_INFO, OP_METRICS, OP_TUNNELS, CHANNEL_TUNNEL, OP_START, OP_STOP, OP_RESTART, OP_UPDATE;
|
|
7724
|
+
var init_envelope = __esm({
|
|
7725
|
+
"src/devices/envelope.ts"() {
|
|
7726
|
+
"use strict";
|
|
7727
|
+
init_cjs_shims();
|
|
7728
|
+
import_crypto2 = require("crypto");
|
|
7729
|
+
PROTOCOL_VERSION = 1;
|
|
7730
|
+
CHANNEL_SYSTEM = "system";
|
|
7731
|
+
CHANNEL_DEVICE = "device";
|
|
7732
|
+
OP_HELLO = "hello";
|
|
7733
|
+
OP_WELCOME = "welcome";
|
|
7734
|
+
OP_HEARTBEAT = "heartbeat";
|
|
7735
|
+
OP_DISCONNECT = "disconnect";
|
|
7736
|
+
OP_INFO = "info";
|
|
7737
|
+
OP_METRICS = "metrics";
|
|
7738
|
+
OP_TUNNELS = "tunnels";
|
|
7739
|
+
CHANNEL_TUNNEL = "tunnel";
|
|
7740
|
+
OP_START = "start";
|
|
7741
|
+
OP_STOP = "stop";
|
|
7742
|
+
OP_RESTART = "restart";
|
|
7743
|
+
OP_UPDATE = "update";
|
|
7744
|
+
}
|
|
7745
|
+
});
|
|
7746
|
+
|
|
7747
|
+
// src/devices/device_schema.ts
|
|
7748
|
+
var import_zod3, WelcomeSchema, ErrorPayloadSchema, DisconnectSchema, TunnelActionSchema;
|
|
7749
|
+
var init_device_schema = __esm({
|
|
7750
|
+
"src/devices/device_schema.ts"() {
|
|
7751
|
+
"use strict";
|
|
7752
|
+
init_cjs_shims();
|
|
7753
|
+
import_zod3 = require("zod");
|
|
7754
|
+
WelcomeSchema = import_zod3.z.object({
|
|
7755
|
+
device_agent_id: import_zod3.z.string(),
|
|
7756
|
+
accepted_proto: import_zod3.z.number(),
|
|
7757
|
+
heartbeat_interval_seconds: import_zod3.z.number(),
|
|
7758
|
+
stats_interval_seconds: import_zod3.z.number(),
|
|
7759
|
+
server_time: import_zod3.z.number(),
|
|
7760
|
+
max_frame_bytes: import_zod3.z.number(),
|
|
7761
|
+
// Optional: a dashboard that predates terminals sends none of these.
|
|
7762
|
+
terminal_enabled: import_zod3.z.boolean().optional(),
|
|
7763
|
+
max_terminals_per_device: import_zod3.z.number().optional(),
|
|
7764
|
+
// No default on the agent. Without it, terminals stay unavailable rather than unbraked.
|
|
7765
|
+
terminal_window_bytes: import_zod3.z.number().optional(),
|
|
7766
|
+
// No default either. Without them the agent runs no expiry timers, and the dashboard's sweep
|
|
7767
|
+
// still ends an expired shell, only later.
|
|
7768
|
+
terminal_idle_timeout_seconds: import_zod3.z.number().optional(),
|
|
7769
|
+
terminal_max_session_seconds: import_zod3.z.number().optional(),
|
|
7770
|
+
// Optional: a dashboard older than these sends neither, and the agent prints only the device id.
|
|
7771
|
+
email: import_zod3.z.string().optional(),
|
|
7772
|
+
device_url: import_zod3.z.string().optional()
|
|
7773
|
+
});
|
|
7774
|
+
ErrorPayloadSchema = import_zod3.z.object({
|
|
7775
|
+
error: import_zod3.z.object({
|
|
7776
|
+
code: import_zod3.z.string(),
|
|
7777
|
+
message: import_zod3.z.string()
|
|
7778
|
+
})
|
|
7779
|
+
});
|
|
7780
|
+
DisconnectSchema = import_zod3.z.object({
|
|
7781
|
+
reason: import_zod3.z.string()
|
|
7782
|
+
});
|
|
7783
|
+
TunnelActionSchema = import_zod3.z.object({
|
|
7784
|
+
tunnel_id: import_zod3.z.string().min(1).max(128).optional(),
|
|
7785
|
+
source: import_zod3.z.enum(["device", "dashboard"]).optional(),
|
|
7786
|
+
config_id: import_zod3.z.string().min(1).max(128).optional(),
|
|
7787
|
+
config: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()).optional()
|
|
7788
|
+
});
|
|
7789
|
+
}
|
|
7790
|
+
});
|
|
7791
|
+
|
|
7792
|
+
// src/devices/deviceAgentReporter.ts
|
|
7793
|
+
function printConnected(welcome) {
|
|
7794
|
+
printer_default.success(`Connected as device ${welcome.device_agent_id}`);
|
|
7795
|
+
if (welcome.email) {
|
|
7796
|
+
printer_default.print(`Signed in as ${welcome.email}`);
|
|
7797
|
+
}
|
|
7798
|
+
if (welcome.device_url) {
|
|
7799
|
+
printer_default.print("Open the device in a browser:");
|
|
7800
|
+
printer_default.print(` ${welcome.device_url}`);
|
|
7801
|
+
}
|
|
7802
|
+
}
|
|
7803
|
+
function formatSeconds(millis) {
|
|
7804
|
+
return (millis / 1e3).toFixed(1);
|
|
7805
|
+
}
|
|
7806
|
+
var printingReporter;
|
|
7807
|
+
var init_deviceAgentReporter = __esm({
|
|
7808
|
+
"src/devices/deviceAgentReporter.ts"() {
|
|
7809
|
+
"use strict";
|
|
7810
|
+
init_cjs_shims();
|
|
7811
|
+
init_printer();
|
|
7812
|
+
printingReporter = {
|
|
7813
|
+
connecting: (server) => printer_default.print(`Connecting to ${server}`),
|
|
7814
|
+
connected: printConnected,
|
|
7815
|
+
reconnecting: (delayMillis, attempt) => printer_default.warn(
|
|
7816
|
+
`Disconnected. Reconnecting in ${formatSeconds(delayMillis)} seconds (attempt ${attempt}).`
|
|
7817
|
+
),
|
|
7818
|
+
warn: (message) => printer_default.warn(message),
|
|
7819
|
+
failed: (error) => printer_default.error(error.message),
|
|
7820
|
+
replacingDevice: (oldDeviceAgentId) => printer_default.print(`This machine was device ${oldDeviceAgentId}. Connecting it with the token from the command.`)
|
|
7821
|
+
};
|
|
7822
|
+
}
|
|
7823
|
+
});
|
|
7824
|
+
|
|
7825
|
+
// src/devices/collectors/systemInfo.ts
|
|
7826
|
+
function collectSystemInfo() {
|
|
7827
|
+
const cpus = import_os3.default.cpus();
|
|
7828
|
+
return {
|
|
7829
|
+
hostname: import_os3.default.hostname(),
|
|
7830
|
+
os: import_os3.default.platform(),
|
|
7831
|
+
os_version: import_os3.default.release(),
|
|
7832
|
+
// os.machine() reports what uname does (x86_64, arm64, aarch64). os.arch() reports the Node
|
|
7833
|
+
// build target (x64), which is not what an operator reads on the machine itself.
|
|
7834
|
+
arch: import_os3.default.machine(),
|
|
7835
|
+
kernel: import_os3.default.type(),
|
|
7836
|
+
// os.cpus() is empty on some Android and container builds.
|
|
7837
|
+
cpu_model: cpus[0]?.model?.trim() || UNKNOWN_CPU_MODEL,
|
|
7838
|
+
cpu_cores: cpus.length,
|
|
7839
|
+
total_memory_bytes: import_os3.default.totalmem()
|
|
7840
|
+
};
|
|
7841
|
+
}
|
|
7842
|
+
var import_os3, UNKNOWN_CPU_MODEL;
|
|
7843
|
+
var init_systemInfo = __esm({
|
|
7844
|
+
"src/devices/collectors/systemInfo.ts"() {
|
|
7845
|
+
"use strict";
|
|
7846
|
+
init_cjs_shims();
|
|
7847
|
+
import_os3 = __toESM(require("os"), 1);
|
|
7848
|
+
UNKNOWN_CPU_MODEL = "unknown";
|
|
7849
|
+
}
|
|
7850
|
+
});
|
|
7851
|
+
|
|
7852
|
+
// src/devices/collectors/metrics.ts
|
|
7853
|
+
function sumCpuTicks(cpus) {
|
|
7854
|
+
let idleTicks = 0;
|
|
7855
|
+
let totalTicks = 0;
|
|
7856
|
+
for (const cpu of cpus) {
|
|
7857
|
+
const { user, nice, sys, idle, irq } = cpu.times;
|
|
7858
|
+
idleTicks += idle;
|
|
7859
|
+
totalTicks += user + nice + sys + idle + irq;
|
|
7860
|
+
}
|
|
7861
|
+
return { idleTicks, totalTicks };
|
|
7862
|
+
}
|
|
7863
|
+
function cpuPercentBetween(earlier, later) {
|
|
7864
|
+
const totalDelta = later.totalTicks - earlier.totalTicks;
|
|
7865
|
+
if (totalDelta <= 0) {
|
|
7866
|
+
return 0;
|
|
7867
|
+
}
|
|
7868
|
+
const busyDelta = totalDelta - (later.idleTicks - earlier.idleTicks);
|
|
7869
|
+
const percent = Math.min(100, Math.max(0, 100 * busyDelta / totalDelta));
|
|
7870
|
+
return Math.round(percent * 10) / 10;
|
|
7871
|
+
}
|
|
7872
|
+
function buildMetrics(readings) {
|
|
7873
|
+
const [oneMinute = 0, fiveMinutes = 0, fifteenMinutes = 0] = readings.loadAverages;
|
|
7874
|
+
return {
|
|
7875
|
+
cpu_percent: readings.cpuPercent,
|
|
7876
|
+
load_avg_1m: oneMinute,
|
|
7877
|
+
load_avg_5m: fiveMinutes,
|
|
7878
|
+
load_avg_15m: fifteenMinutes,
|
|
7879
|
+
memory_used_bytes: readings.totalMemoryBytes - readings.freeMemoryBytes,
|
|
7880
|
+
memory_total_bytes: readings.totalMemoryBytes,
|
|
7881
|
+
uptime_seconds: Math.floor(readings.uptimeSeconds),
|
|
7882
|
+
collected_at: readings.collectedAtEpochSeconds
|
|
7883
|
+
};
|
|
7884
|
+
}
|
|
7885
|
+
async function collectMetrics() {
|
|
7886
|
+
const earlier = sumCpuTicks(import_os4.default.cpus());
|
|
7887
|
+
await new Promise((resolve) => setTimeout(resolve, CPU_SAMPLE_WINDOW_MS));
|
|
7888
|
+
const later = sumCpuTicks(import_os4.default.cpus());
|
|
7889
|
+
return buildMetrics({
|
|
7890
|
+
cpuPercent: cpuPercentBetween(earlier, later),
|
|
7891
|
+
loadAverages: import_os4.default.loadavg(),
|
|
7892
|
+
totalMemoryBytes: import_os4.default.totalmem(),
|
|
7893
|
+
freeMemoryBytes: import_os4.default.freemem(),
|
|
7894
|
+
uptimeSeconds: import_os4.default.uptime(),
|
|
7895
|
+
collectedAtEpochSeconds: Math.floor(Date.now() / 1e3)
|
|
7896
|
+
});
|
|
7897
|
+
}
|
|
7898
|
+
var import_os4, CPU_SAMPLE_WINDOW_MS;
|
|
7899
|
+
var init_metrics = __esm({
|
|
7900
|
+
"src/devices/collectors/metrics.ts"() {
|
|
7901
|
+
"use strict";
|
|
7902
|
+
init_cjs_shims();
|
|
7903
|
+
import_os4 = __toESM(require("os"), 1);
|
|
7904
|
+
CPU_SAMPLE_WINDOW_MS = 200;
|
|
7905
|
+
}
|
|
7906
|
+
});
|
|
7907
|
+
|
|
7908
|
+
// src/devices/tunnels/tunnelList.ts
|
|
7909
|
+
async function readDaemonOnPort(port) {
|
|
7910
|
+
try {
|
|
7911
|
+
const answer = await new IPCClient(port, "cli").listTunnels();
|
|
7912
|
+
return { running: true, tunnels: Array.isArray(answer) ? answer : [] };
|
|
7913
|
+
} catch (err) {
|
|
7914
|
+
logger.debug("Device agent could not list the daemon's tunnels", { error: String(err) });
|
|
7915
|
+
return { running: false, reason: REASON_DAEMON_UNREACHABLE };
|
|
7916
|
+
}
|
|
7917
|
+
}
|
|
7918
|
+
function selfTunnelListSource(port) {
|
|
7919
|
+
return {
|
|
7920
|
+
readDaemon: () => readDaemonOnPort(port),
|
|
7921
|
+
readSavedConfigs: listSavedConfigs
|
|
7922
|
+
};
|
|
7923
|
+
}
|
|
7924
|
+
function isObject(value) {
|
|
7925
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7926
|
+
}
|
|
7927
|
+
function stringOrNull(value) {
|
|
7928
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
7929
|
+
}
|
|
7930
|
+
function toEpochSeconds(value) {
|
|
7931
|
+
if (typeof value !== "string" || value === "") return null;
|
|
7932
|
+
const millis = Date.parse(value);
|
|
7933
|
+
return Number.isNaN(millis) ? null : Math.floor(millis / 1e3);
|
|
7934
|
+
}
|
|
7935
|
+
function sanitizeErrorMessage(value) {
|
|
7936
|
+
if (typeof value !== "string") return null;
|
|
7937
|
+
const printable = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").trim();
|
|
7938
|
+
return printable === "" ? null : printable.slice(0, ERROR_MESSAGE_MAX_LENGTH);
|
|
7939
|
+
}
|
|
7940
|
+
function forwardingType(value) {
|
|
7941
|
+
return typeof value === "string" && FORWARDING_TYPES.has(value) ? value : DEFAULT_FORWARDING_TYPE;
|
|
7942
|
+
}
|
|
7943
|
+
function toForwarding(forwarding) {
|
|
7944
|
+
if (typeof forwarding === "string") {
|
|
7945
|
+
if (forwarding.trim() === "") return [];
|
|
7946
|
+
const schemeMatch = /^([a-z]+):\/\/(.+)$/i.exec(forwarding.trim());
|
|
7947
|
+
if (!schemeMatch) {
|
|
7948
|
+
return [{ type: DEFAULT_FORWARDING_TYPE, local_address: forwarding.trim() }];
|
|
7949
|
+
}
|
|
7950
|
+
const scheme = schemeMatch[1].toLowerCase();
|
|
7951
|
+
return [{
|
|
7952
|
+
type: scheme === "https" ? DEFAULT_FORWARDING_TYPE : forwardingType(scheme),
|
|
7953
|
+
local_address: schemeMatch[2]
|
|
7954
|
+
}];
|
|
7955
|
+
}
|
|
7956
|
+
if (!Array.isArray(forwarding)) return [];
|
|
7957
|
+
return forwarding.filter(isObject).filter((entry) => typeof entry.address === "string").map((entry) => ({ type: forwardingType(entry.type), local_address: entry.address }));
|
|
7958
|
+
}
|
|
7959
|
+
function toFullConfig(rawConfig) {
|
|
7960
|
+
const parsed = TunnelConfigV1Schema.safeParse(rawConfig);
|
|
7961
|
+
return parsed.success ? parsed.data : null;
|
|
7962
|
+
}
|
|
7963
|
+
function toTunnelRow(daemonTunnel) {
|
|
7964
|
+
if (!isObject(daemonTunnel) || typeof daemonTunnel.tunnelid !== "string") return null;
|
|
7965
|
+
const config = isObject(daemonTunnel.tunnelconfig) ? daemonTunnel.tunnelconfig : {};
|
|
7966
|
+
const status = isObject(daemonTunnel.status) ? daemonTunnel.status : {};
|
|
7967
|
+
const remoteUrls = Array.isArray(daemonTunnel.remoteurls) ? daemonTunnel.remoteurls.filter((url) => typeof url === "string") : [];
|
|
7968
|
+
return {
|
|
7969
|
+
tunnel_id: daemonTunnel.tunnelid,
|
|
7970
|
+
config_id: stringOrNull(config.configId),
|
|
7971
|
+
name: stringOrNull(config.name),
|
|
7972
|
+
state: typeof status.state === "string" && STATES.has(status.state) ? status.state : "idle" /* New */,
|
|
7973
|
+
error_message: sanitizeErrorMessage(status.errormsg),
|
|
7974
|
+
remote_urls: remoteUrls,
|
|
7975
|
+
forwarding: toForwarding(config.forwarding),
|
|
7976
|
+
mode: typeof daemonTunnel.mode === "string" && MODES.has(daemonTunnel.mode) ? daemonTunnel.mode : null,
|
|
7977
|
+
created_at: toEpochSeconds(status.createdtimestamp),
|
|
7978
|
+
started_at: toEpochSeconds(status.starttimestamp),
|
|
7979
|
+
tunnel_config: toFullConfig(daemonTunnel.tunnelconfig)
|
|
7980
|
+
};
|
|
7981
|
+
}
|
|
7982
|
+
function newestFirst(rows, timeOf) {
|
|
7983
|
+
return [...rows].sort((a, b) => (timeOf(b) ?? 0) - (timeOf(a) ?? 0));
|
|
7984
|
+
}
|
|
7985
|
+
function buildTunnelList(reading, savedConfigs, collectedAtEpochSeconds) {
|
|
7986
|
+
const allTunnels = reading.running ? newestFirst(
|
|
7987
|
+
reading.tunnels.map(toTunnelRow).filter((row) => row !== null),
|
|
7988
|
+
(row) => row.created_at
|
|
7989
|
+
) : [];
|
|
7990
|
+
const runningConfigIds = new Set(allTunnels.filter((row) => row.config_id !== null && !STOPPED_STATES.has(row.state)).map((row) => row.config_id));
|
|
7991
|
+
const allSaved = newestFirst(savedConfigs, (config) => toEpochSeconds(config.createdAt)).map((config) => ({
|
|
7992
|
+
config_id: config.configId,
|
|
7993
|
+
name: config.name,
|
|
7994
|
+
running: runningConfigIds.has(config.configId),
|
|
7995
|
+
// The same shape tunnelActions.ts sends the daemon to start this exact config.
|
|
7996
|
+
tunnel_config: { ...config.tunnelConfig, configId: config.configId, name: config.name }
|
|
7997
|
+
}));
|
|
7998
|
+
return {
|
|
7999
|
+
daemon_running: reading.running,
|
|
8000
|
+
daemon_unavailable_reason: reading.running ? null : reading.reason,
|
|
8001
|
+
tunnels: allTunnels.slice(0, MAX_TUNNELS),
|
|
8002
|
+
saved_configs: allSaved.slice(0, MAX_SAVED_CONFIGS),
|
|
8003
|
+
truncated: allTunnels.length > MAX_TUNNELS || allSaved.length > MAX_SAVED_CONFIGS,
|
|
8004
|
+
collected_at: collectedAtEpochSeconds
|
|
8005
|
+
};
|
|
8006
|
+
}
|
|
8007
|
+
async function collectTunnelList(source = machineTunnelListSource) {
|
|
8008
|
+
const reading = await source.readDaemon();
|
|
8009
|
+
let savedConfigs = [];
|
|
8010
|
+
try {
|
|
8011
|
+
savedConfigs = source.readSavedConfigs();
|
|
8012
|
+
} catch (err) {
|
|
8013
|
+
logger.debug("Device agent could not read saved tunnel configs", { error: String(err) });
|
|
8014
|
+
}
|
|
8015
|
+
return buildTunnelList(reading, savedConfigs, Math.floor(Date.now() / 1e3));
|
|
8016
|
+
}
|
|
8017
|
+
function changeKey(list) {
|
|
8018
|
+
const { collected_at: _collectedAt, ...comparable } = list;
|
|
8019
|
+
return JSON.stringify(comparable);
|
|
8020
|
+
}
|
|
8021
|
+
function startTunnelReporting(collect, send, intervalMillis = TUNNEL_POLL_INTERVAL_MILLIS) {
|
|
8022
|
+
let stopped = false;
|
|
8023
|
+
let inFlight = false;
|
|
8024
|
+
let pollAgain = false;
|
|
8025
|
+
let lastSentKey = null;
|
|
8026
|
+
const poll = () => {
|
|
8027
|
+
if (stopped) return;
|
|
8028
|
+
if (inFlight) {
|
|
8029
|
+
pollAgain = true;
|
|
8030
|
+
return;
|
|
8031
|
+
}
|
|
8032
|
+
inFlight = true;
|
|
8033
|
+
collect().then((list) => {
|
|
8034
|
+
if (stopped) return;
|
|
8035
|
+
const key = changeKey(list);
|
|
8036
|
+
if (key === lastSentKey) return;
|
|
8037
|
+
lastSentKey = key;
|
|
8038
|
+
send(list);
|
|
8039
|
+
}).catch((err) => logger.warn("Device tunnel list collection failed", { error: String(err) })).finally(() => {
|
|
8040
|
+
inFlight = false;
|
|
8041
|
+
if (pollAgain) {
|
|
8042
|
+
pollAgain = false;
|
|
8043
|
+
poll();
|
|
8044
|
+
}
|
|
8045
|
+
});
|
|
8046
|
+
};
|
|
8047
|
+
poll();
|
|
8048
|
+
const timer = setInterval(poll, intervalMillis);
|
|
8049
|
+
const stop = () => {
|
|
8050
|
+
stopped = true;
|
|
8051
|
+
clearInterval(timer);
|
|
8052
|
+
};
|
|
8053
|
+
return Object.assign(stop, { pollNow: poll });
|
|
8054
|
+
}
|
|
8055
|
+
var TUNNEL_POLL_INTERVAL_MILLIS, MAX_TUNNELS, MAX_SAVED_CONFIGS, ERROR_MESSAGE_MAX_LENGTH, REASON_IPC_VERSION_MISMATCH, REASON_DAEMON_UNREACHABLE, FORWARDING_TYPES, DEFAULT_FORWARDING_TYPE, MODES, STATES, STOPPED_STATES, machineTunnelListSource;
|
|
8056
|
+
var init_tunnelList = __esm({
|
|
8057
|
+
"src/devices/tunnels/tunnelList.ts"() {
|
|
8058
|
+
"use strict";
|
|
8059
|
+
init_cjs_shims();
|
|
8060
|
+
init_logger();
|
|
8061
|
+
init_configStore();
|
|
8062
|
+
init_daemonManager();
|
|
8063
|
+
init_ipcClient();
|
|
8064
|
+
init_remote_schema();
|
|
8065
|
+
init_types();
|
|
8066
|
+
TUNNEL_POLL_INTERVAL_MILLIS = 5e3;
|
|
8067
|
+
MAX_TUNNELS = 50;
|
|
8068
|
+
MAX_SAVED_CONFIGS = 100;
|
|
8069
|
+
ERROR_MESSAGE_MAX_LENGTH = 256;
|
|
8070
|
+
REASON_IPC_VERSION_MISMATCH = "ipc_version_mismatch";
|
|
8071
|
+
REASON_DAEMON_UNREACHABLE = "daemon_unreachable";
|
|
8072
|
+
FORWARDING_TYPES = /* @__PURE__ */ new Set(["http", "tcp", "udp", "tls", "tlstcp"]);
|
|
8073
|
+
DEFAULT_FORWARDING_TYPE = "http";
|
|
8074
|
+
MODES = /* @__PURE__ */ new Set(["foreground", "detached"]);
|
|
8075
|
+
STATES = new Set(Object.values(TunnelStateType));
|
|
8076
|
+
STOPPED_STATES = /* @__PURE__ */ new Set(["closed" /* Closed */, "exited" /* Exited */]);
|
|
8077
|
+
machineTunnelListSource = {
|
|
8078
|
+
async readDaemon() {
|
|
8079
|
+
const info = getDaemonInfo();
|
|
8080
|
+
if (!info) {
|
|
8081
|
+
return { running: false, reason: null };
|
|
8082
|
+
}
|
|
8083
|
+
if (!isIpcCompatible(info)) {
|
|
8084
|
+
return { running: false, reason: REASON_IPC_VERSION_MISMATCH };
|
|
8085
|
+
}
|
|
8086
|
+
return readDaemonOnPort(info.port);
|
|
8087
|
+
},
|
|
8088
|
+
readSavedConfigs: listSavedConfigs
|
|
8089
|
+
};
|
|
8090
|
+
}
|
|
8091
|
+
});
|
|
8092
|
+
|
|
8093
|
+
// src/devices/tunnels/tunnelActions.ts
|
|
8094
|
+
function selfTunnelActionDependencies(port) {
|
|
8095
|
+
return {
|
|
8096
|
+
daemon: () => Promise.resolve(new IPCClient(port, "device")),
|
|
8097
|
+
readSavedConfigs: machineTunnelActionDependencies.readSavedConfigs,
|
|
8098
|
+
writeSavedConfig: machineTunnelActionDependencies.writeSavedConfig
|
|
8099
|
+
};
|
|
8100
|
+
}
|
|
8101
|
+
function failure(code, message) {
|
|
8102
|
+
return { error: { code, message } };
|
|
8103
|
+
}
|
|
8104
|
+
function actionFailed(message) {
|
|
8105
|
+
return failure(ERROR_TUNNEL_ACTION_FAILED, sanitizeErrorMessage(message) ?? "The tunnel action failed.");
|
|
8106
|
+
}
|
|
8107
|
+
function toResult(daemonAnswer, fallbackTunnelId) {
|
|
8108
|
+
if (isErrorResponse(daemonAnswer)) {
|
|
8109
|
+
if (daemonAnswer.code === ErrorCode.TunnelNotFound) {
|
|
8110
|
+
return failure(ERROR_TUNNEL_NOT_FOUND, sanitizeErrorMessage(daemonAnswer.message) ?? "No such tunnel.");
|
|
8111
|
+
}
|
|
8112
|
+
return actionFailed(daemonAnswer.message);
|
|
8113
|
+
}
|
|
8114
|
+
const answer = typeof daemonAnswer === "object" && daemonAnswer !== null ? daemonAnswer : {};
|
|
8115
|
+
const tunnelId = typeof answer.tunnelid === "string" && answer.tunnelid !== "" ? answer.tunnelid : fallbackTunnelId;
|
|
8116
|
+
if (!tunnelId) {
|
|
8117
|
+
return actionFailed("The daemon did not name the tunnel.");
|
|
8118
|
+
}
|
|
8119
|
+
const state = typeof answer.status?.state === "string" ? answer.status.state : STATE_STARTING;
|
|
8120
|
+
return { tunnel_id: tunnelId, state };
|
|
8121
|
+
}
|
|
8122
|
+
function withoutNulls(config) {
|
|
8123
|
+
return Object.fromEntries(Object.entries(config).filter(([, value]) => value !== null));
|
|
8124
|
+
}
|
|
8125
|
+
function targetOf(op, action) {
|
|
8126
|
+
const namesTunnel = action.tunnel_id !== void 0;
|
|
8127
|
+
if (op === OP_UPDATE) {
|
|
8128
|
+
if (namesTunnel || action.config === void 0) {
|
|
8129
|
+
return null;
|
|
8130
|
+
}
|
|
8131
|
+
if (action.source === "dashboard" && action.config_id === void 0) {
|
|
8132
|
+
return { kind: "dashboard", config: action.config };
|
|
8133
|
+
}
|
|
8134
|
+
if (action.source === "device" && action.config_id !== void 0) {
|
|
8135
|
+
return { kind: "savedEdit", configId: action.config_id, config: action.config };
|
|
8136
|
+
}
|
|
8137
|
+
return null;
|
|
8138
|
+
}
|
|
8139
|
+
if (op !== OP_START) {
|
|
8140
|
+
return namesTunnel ? { kind: "tunnel", tunnelId: action.tunnel_id } : null;
|
|
8141
|
+
}
|
|
8142
|
+
if (namesTunnel) {
|
|
8143
|
+
return action.source === void 0 && action.config_id === void 0 && action.config === void 0 ? { kind: "tunnel", tunnelId: action.tunnel_id } : null;
|
|
8144
|
+
}
|
|
8145
|
+
if (action.source === "device" && action.config_id !== void 0 && action.config === void 0) {
|
|
8146
|
+
return { kind: "saved", configId: action.config_id };
|
|
8147
|
+
}
|
|
8148
|
+
if (action.source === "dashboard" && action.config !== void 0 && action.config_id === void 0) {
|
|
8149
|
+
return { kind: "dashboard", config: action.config };
|
|
8150
|
+
}
|
|
8151
|
+
return null;
|
|
8152
|
+
}
|
|
8153
|
+
async function perform(op, action, dependencies) {
|
|
8154
|
+
const target = targetOf(op, action);
|
|
8155
|
+
if (!target) {
|
|
8156
|
+
return failure(ERROR_INVALID_PAYLOAD, "The tunnel request names no tunnel or config.");
|
|
8157
|
+
}
|
|
8158
|
+
if (target.kind === "tunnel") {
|
|
8159
|
+
const client2 = await dependencies.daemon(false);
|
|
8160
|
+
if (!client2) {
|
|
8161
|
+
return NO_DAEMON;
|
|
8162
|
+
}
|
|
8163
|
+
const answer = op === OP_STOP ? await client2.stopTunnel(target.tunnelId) : await client2.restartTunnel(target.tunnelId, true);
|
|
8164
|
+
return toResult(answer, target.tunnelId);
|
|
8165
|
+
}
|
|
8166
|
+
if (target.kind === "savedEdit") {
|
|
8167
|
+
return rewriteSavedConfig(target.configId, target.config, dependencies);
|
|
8168
|
+
}
|
|
8169
|
+
let config;
|
|
8170
|
+
if (target.kind === "saved") {
|
|
8171
|
+
const saved = dependencies.readSavedConfigs().find((candidate) => candidate.configId === target.configId);
|
|
8172
|
+
if (!saved) {
|
|
8173
|
+
return failure(ERROR_SAVED_CONFIG_NOT_FOUND, "No config with that id is saved on this machine.");
|
|
8174
|
+
}
|
|
8175
|
+
config = { ...saved.tunnelConfig, configId: saved.configId, name: saved.name };
|
|
8176
|
+
} else {
|
|
8177
|
+
const parsed = TunnelConfigV1Schema.safeParse(withoutNulls(target.config));
|
|
8178
|
+
if (!parsed.success) {
|
|
8179
|
+
return failure(ERROR_INVALID_PAYLOAD, "The dashboard config could not be read.");
|
|
8180
|
+
}
|
|
8181
|
+
config = parsed.data;
|
|
8182
|
+
}
|
|
8183
|
+
if (op === OP_UPDATE) {
|
|
8184
|
+
return updateTunnel(config, dependencies);
|
|
8185
|
+
}
|
|
8186
|
+
const client = await dependencies.daemon(true);
|
|
8187
|
+
if (!client) {
|
|
8188
|
+
return actionFailed("The tunnel daemon could not be started.");
|
|
8189
|
+
}
|
|
8190
|
+
return toResult(await client.startTunnelWithConfig(config, SessionMode.Detached, true), null);
|
|
8191
|
+
}
|
|
8192
|
+
function rewriteSavedConfig(configId, rawConfig, dependencies) {
|
|
8193
|
+
const parsed = TunnelConfigV1Schema.safeParse(withoutNulls(rawConfig));
|
|
8194
|
+
if (!parsed.success) {
|
|
8195
|
+
return failure(ERROR_INVALID_PAYLOAD, "The config could not be read.");
|
|
8196
|
+
}
|
|
8197
|
+
const savedConfigs = dependencies.readSavedConfigs();
|
|
8198
|
+
const saved = savedConfigs.find((candidate) => candidate.configId === configId);
|
|
8199
|
+
if (!saved) {
|
|
8200
|
+
return failure(ERROR_SAVED_CONFIG_NOT_FOUND, "No config with that id is saved on this machine.");
|
|
8201
|
+
}
|
|
8202
|
+
const name = parsed.data.name || saved.name;
|
|
8203
|
+
if (name !== saved.name) {
|
|
8204
|
+
const nameError = validateNameStrict(name);
|
|
8205
|
+
if (nameError) {
|
|
8206
|
+
return failure(ERROR_INVALID_PAYLOAD, nameError.message);
|
|
8207
|
+
}
|
|
8208
|
+
if (savedConfigs.some((candidate) => candidate.name === name)) {
|
|
8209
|
+
return failure(ERROR_SAVED_CONFIG_NAME_TAKEN, `A config named "${name}" is already saved on this machine.`);
|
|
8210
|
+
}
|
|
8211
|
+
}
|
|
8212
|
+
dependencies.writeSavedConfig({
|
|
8213
|
+
...saved,
|
|
8214
|
+
name,
|
|
8215
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8216
|
+
tunnelConfig: { ...parsed.data, configId, name }
|
|
8217
|
+
});
|
|
8218
|
+
return { config_id: configId };
|
|
8219
|
+
}
|
|
8220
|
+
async function updateTunnel(config, dependencies) {
|
|
8221
|
+
const client = await dependencies.daemon(false);
|
|
8222
|
+
if (!client) {
|
|
8223
|
+
return NO_DAEMON;
|
|
8224
|
+
}
|
|
8225
|
+
const listed = await client.listTunnels();
|
|
8226
|
+
if (!Array.isArray(listed)) {
|
|
8227
|
+
return actionFailed(isErrorResponse(listed) ? listed.message : "The daemon did not list its tunnels.");
|
|
8228
|
+
}
|
|
8229
|
+
const running = listed.find((candidate) => candidate.tunnelconfig?.configId === config.configId);
|
|
8230
|
+
if (!running) {
|
|
8231
|
+
return failure(ERROR_TUNNEL_NOT_FOUND, "No tunnel on this machine runs that config.");
|
|
8232
|
+
}
|
|
8233
|
+
return toResult(await client.updateConfigV2(config, true), running.tunnelid);
|
|
8234
|
+
}
|
|
8235
|
+
async function handleTunnelAction(envelope, send, dependencies = machineTunnelActionDependencies) {
|
|
8236
|
+
if (envelope.kind !== "req") {
|
|
8237
|
+
return;
|
|
8238
|
+
}
|
|
8239
|
+
const op = envelope.op;
|
|
8240
|
+
if (op !== OP_START && op !== OP_STOP && op !== OP_RESTART && op !== OP_UPDATE) {
|
|
8241
|
+
send(response(envelope, op, failure(ERROR_UNSUPPORTED_OP, `Unsupported operation: tunnel/${op}`)));
|
|
8242
|
+
return;
|
|
8243
|
+
}
|
|
8244
|
+
const parsed = TunnelActionSchema.safeParse(envelope.payload);
|
|
8245
|
+
if (!parsed.success) {
|
|
8246
|
+
send(response(envelope, op, failure(ERROR_INVALID_PAYLOAD, "The tunnel request could not be read.")));
|
|
8247
|
+
return;
|
|
8248
|
+
}
|
|
8249
|
+
let result;
|
|
8250
|
+
try {
|
|
8251
|
+
result = await perform(op, parsed.data, dependencies);
|
|
8252
|
+
} catch (err) {
|
|
8253
|
+
result = actionFailed(err instanceof Error ? err.message : String(err));
|
|
8254
|
+
}
|
|
8255
|
+
logger.info("Device agent tunnel action", {
|
|
8256
|
+
op,
|
|
8257
|
+
tunnelId: parsed.data.tunnel_id ?? null,
|
|
8258
|
+
configId: parsed.data.config_id ?? null,
|
|
8259
|
+
source: parsed.data.source ?? null,
|
|
8260
|
+
outcome: "error" in result ? result.error.code : "accepted"
|
|
8261
|
+
});
|
|
8262
|
+
send(response(envelope, op, result));
|
|
8263
|
+
}
|
|
8264
|
+
var ERROR_INVALID_PAYLOAD, ERROR_UNSUPPORTED_OP, ERROR_TUNNEL_NOT_FOUND, ERROR_SAVED_CONFIG_NOT_FOUND, ERROR_SAVED_CONFIG_NAME_TAKEN, ERROR_TUNNEL_ACTION_FAILED, STATE_STARTING, DaemonUnusableError, machineTunnelActionDependencies, NO_DAEMON;
|
|
8265
|
+
var init_tunnelActions = __esm({
|
|
8266
|
+
"src/devices/tunnels/tunnelActions.ts"() {
|
|
8267
|
+
"use strict";
|
|
8268
|
+
init_cjs_shims();
|
|
8269
|
+
init_logger();
|
|
8270
|
+
init_configStore();
|
|
8271
|
+
init_daemonManager();
|
|
8272
|
+
init_ipcClient();
|
|
8273
|
+
init_ipcRoutes();
|
|
8274
|
+
init_remote_schema();
|
|
8275
|
+
init_types();
|
|
8276
|
+
init_envelope();
|
|
8277
|
+
init_device_schema();
|
|
8278
|
+
init_tunnelList();
|
|
8279
|
+
ERROR_INVALID_PAYLOAD = "invalid_payload";
|
|
8280
|
+
ERROR_UNSUPPORTED_OP = "unsupported_op";
|
|
8281
|
+
ERROR_TUNNEL_NOT_FOUND = "tunnel_not_found";
|
|
8282
|
+
ERROR_SAVED_CONFIG_NOT_FOUND = "saved_config_not_found";
|
|
8283
|
+
ERROR_SAVED_CONFIG_NAME_TAKEN = "saved_config_name_taken";
|
|
8284
|
+
ERROR_TUNNEL_ACTION_FAILED = "tunnel_action_failed";
|
|
8285
|
+
STATE_STARTING = "starting";
|
|
8286
|
+
DaemonUnusableError = class extends Error {
|
|
8287
|
+
};
|
|
8288
|
+
machineTunnelActionDependencies = {
|
|
8289
|
+
async daemon(startIfMissing) {
|
|
8290
|
+
const info = getDaemonInfo();
|
|
8291
|
+
if (info) {
|
|
8292
|
+
if (!isIpcCompatible(info)) {
|
|
8293
|
+
throw new DaemonUnusableError(ipcMismatchMessage(info));
|
|
8294
|
+
}
|
|
8295
|
+
return new IPCClient(info.port, "device");
|
|
8296
|
+
}
|
|
8297
|
+
if (!startIfMissing) {
|
|
8298
|
+
return null;
|
|
8299
|
+
}
|
|
8300
|
+
const started = await ensureDaemonRunning();
|
|
8301
|
+
return new IPCClient(started.port, "device");
|
|
8302
|
+
},
|
|
8303
|
+
readSavedConfigs: listSavedConfigs,
|
|
8304
|
+
writeSavedConfig: (saved) => {
|
|
8305
|
+
upsertConfig(saved);
|
|
8306
|
+
}
|
|
8307
|
+
};
|
|
8308
|
+
NO_DAEMON = failure(ERROR_TUNNEL_NOT_FOUND, "No tunnel daemon is running on this machine.");
|
|
8309
|
+
}
|
|
8310
|
+
});
|
|
8311
|
+
|
|
8312
|
+
// src/devices/reconnect.ts
|
|
8313
|
+
function backoffCeilingMillis(attempt) {
|
|
8314
|
+
const uncapped = INITIAL_DELAY_MILLIS * 2 ** Math.max(0, attempt);
|
|
8315
|
+
return Math.min(uncapped, MAX_DELAY_MILLIS);
|
|
8316
|
+
}
|
|
8317
|
+
function fullJitterMillis(ceilingMillis, random) {
|
|
8318
|
+
return Math.floor(random() * ceilingMillis);
|
|
8319
|
+
}
|
|
8320
|
+
var INITIAL_DELAY_MILLIS, MAX_DELAY_MILLIS, STABLE_CONNECTION_MILLIS, MAX_ATTEMPT, ReconnectPolicy;
|
|
8321
|
+
var init_reconnect = __esm({
|
|
8322
|
+
"src/devices/reconnect.ts"() {
|
|
8323
|
+
"use strict";
|
|
8324
|
+
init_cjs_shims();
|
|
8325
|
+
INITIAL_DELAY_MILLIS = 1e3;
|
|
8326
|
+
MAX_DELAY_MILLIS = 6e4;
|
|
8327
|
+
STABLE_CONNECTION_MILLIS = 6e4;
|
|
8328
|
+
MAX_ATTEMPT = Math.ceil(Math.log2(MAX_DELAY_MILLIS / INITIAL_DELAY_MILLIS));
|
|
8329
|
+
ReconnectPolicy = class {
|
|
8330
|
+
constructor(options = {}) {
|
|
8331
|
+
this.attemptCount = 0;
|
|
8332
|
+
this.connectedAtMillis = null;
|
|
8333
|
+
this.now = options.now ?? Date.now;
|
|
8334
|
+
this.random = options.random ?? Math.random;
|
|
8335
|
+
}
|
|
8336
|
+
/** Attempts taken since the last reset. Exposed for logging and for tests. */
|
|
8337
|
+
get attempt() {
|
|
8338
|
+
return this.attemptCount;
|
|
8339
|
+
}
|
|
8340
|
+
/** The ceiling the next delay will be drawn from, before jitter. */
|
|
8341
|
+
get nextCeilingMillis() {
|
|
8342
|
+
return backoffCeilingMillis(this.attemptCount);
|
|
8343
|
+
}
|
|
8344
|
+
markConnected() {
|
|
8345
|
+
this.connectedAtMillis = this.now();
|
|
8346
|
+
}
|
|
8347
|
+
/**
|
|
8348
|
+
* Ends the current connection. Returns true when it had held long enough to reset the
|
|
8349
|
+
* schedule, which the caller logs.
|
|
8350
|
+
*/
|
|
8351
|
+
markDisconnected() {
|
|
8352
|
+
const heldMillis = this.connectedAtMillis === null ? 0 : this.now() - this.connectedAtMillis;
|
|
8353
|
+
this.connectedAtMillis = null;
|
|
8354
|
+
const wasStable = heldMillis >= STABLE_CONNECTION_MILLIS;
|
|
8355
|
+
if (wasStable) {
|
|
8356
|
+
this.attemptCount = 0;
|
|
8357
|
+
}
|
|
8358
|
+
return wasStable;
|
|
8359
|
+
}
|
|
8360
|
+
/** The delay before the next dial, and advances the schedule. */
|
|
8361
|
+
nextDelayMillis() {
|
|
8362
|
+
const delay = fullJitterMillis(this.nextCeilingMillis, this.random);
|
|
8363
|
+
this.attemptCount = Math.min(this.attemptCount + 1, MAX_ATTEMPT);
|
|
8364
|
+
return delay;
|
|
8365
|
+
}
|
|
8366
|
+
};
|
|
8367
|
+
}
|
|
8368
|
+
});
|
|
8369
|
+
|
|
8370
|
+
// src/devices/terminal/terminal_schema.ts
|
|
8371
|
+
function clampGrid(requested, fallback) {
|
|
8372
|
+
if (requested === void 0 || !Number.isFinite(requested)) return fallback;
|
|
8373
|
+
return Math.max(MIN_GRID, Math.min(MAX_GRID, Math.trunc(requested)));
|
|
8374
|
+
}
|
|
8375
|
+
var import_zod4, CHANNEL_TERMINAL, OP_OPEN, OP_OPENED, OP_CLOSE, OP_DATA, OP_ACK, OP_EXIT, OP_RESIZE, OP_SIGNAL, OP_CONTEXT, OP_SNAPSHOT, TERMINAL_SIGNALS, MIN_GRID, MAX_GRID, CLOSE_REASON_USER_CLOSED, CLOSE_REASON_IDLE_TIMEOUT, CLOSE_REASON_MAX_SESSION, ERROR_INVALID_PAYLOAD2, ERROR_TERMINAL_LIMIT_REACHED, ERROR_TERMINAL_DISABLED, ERROR_SHELL_NOT_ALLOWED, ERROR_SPAWN_FAILED, TerminalOpenSchema, TerminalCloseSchema, TerminalResizeSchema, TerminalInputSchema, TerminalSignalSchema, TerminalAckSchema, TerminalSnapshotRequestSchema;
|
|
8376
|
+
var init_terminal_schema = __esm({
|
|
8377
|
+
"src/devices/terminal/terminal_schema.ts"() {
|
|
8378
|
+
"use strict";
|
|
8379
|
+
init_cjs_shims();
|
|
8380
|
+
import_zod4 = require("zod");
|
|
8381
|
+
CHANNEL_TERMINAL = "terminal";
|
|
8382
|
+
OP_OPEN = "open";
|
|
8383
|
+
OP_OPENED = "opened";
|
|
8384
|
+
OP_CLOSE = "close";
|
|
8385
|
+
OP_DATA = "data";
|
|
8386
|
+
OP_ACK = "ack";
|
|
8387
|
+
OP_EXIT = "exit";
|
|
8388
|
+
OP_RESIZE = "resize";
|
|
8389
|
+
OP_SIGNAL = "signal";
|
|
8390
|
+
OP_CONTEXT = "context";
|
|
8391
|
+
OP_SNAPSHOT = "snapshot";
|
|
8392
|
+
TERMINAL_SIGNALS = ["INT", "TERM", "QUIT", "HUP"];
|
|
8393
|
+
MIN_GRID = 1;
|
|
8394
|
+
MAX_GRID = 1e3;
|
|
8395
|
+
CLOSE_REASON_USER_CLOSED = "user_closed";
|
|
8396
|
+
CLOSE_REASON_IDLE_TIMEOUT = "idle_timeout";
|
|
8397
|
+
CLOSE_REASON_MAX_SESSION = "max_session";
|
|
8398
|
+
ERROR_INVALID_PAYLOAD2 = "invalid_payload";
|
|
8399
|
+
ERROR_TERMINAL_LIMIT_REACHED = "terminal_limit_reached";
|
|
8400
|
+
ERROR_TERMINAL_DISABLED = "terminal_disabled";
|
|
8401
|
+
ERROR_SHELL_NOT_ALLOWED = "shell_not_allowed";
|
|
8402
|
+
ERROR_SPAWN_FAILED = "spawn_failed";
|
|
8403
|
+
TerminalOpenSchema = import_zod4.z.object({
|
|
8404
|
+
terminal_id: import_zod4.z.string().min(1),
|
|
8405
|
+
cols: import_zod4.z.number().int().optional(),
|
|
8406
|
+
rows: import_zod4.z.number().int().optional(),
|
|
8407
|
+
shell: import_zod4.z.string().nullable().optional(),
|
|
8408
|
+
cwd: import_zod4.z.string().nullable().optional()
|
|
8409
|
+
});
|
|
8410
|
+
TerminalCloseSchema = import_zod4.z.object({
|
|
8411
|
+
terminal_id: import_zod4.z.string().min(1),
|
|
8412
|
+
reason: import_zod4.z.string().optional()
|
|
8413
|
+
});
|
|
8414
|
+
TerminalResizeSchema = import_zod4.z.object({
|
|
8415
|
+
terminal_id: import_zod4.z.string().min(1),
|
|
8416
|
+
cols: import_zod4.z.number().int(),
|
|
8417
|
+
rows: import_zod4.z.number().int()
|
|
8418
|
+
});
|
|
8419
|
+
TerminalInputSchema = import_zod4.z.object({
|
|
8420
|
+
terminal_id: import_zod4.z.string().min(1),
|
|
8421
|
+
data: import_zod4.z.string()
|
|
8422
|
+
});
|
|
8423
|
+
TerminalSignalSchema = import_zod4.z.object({
|
|
8424
|
+
terminal_id: import_zod4.z.string().min(1),
|
|
8425
|
+
signal: import_zod4.z.enum(TERMINAL_SIGNALS)
|
|
8426
|
+
});
|
|
8427
|
+
TerminalAckSchema = import_zod4.z.object({
|
|
8428
|
+
terminal_id: import_zod4.z.string().min(1),
|
|
8429
|
+
ack_seq: import_zod4.z.number().optional(),
|
|
8430
|
+
ack_bytes: import_zod4.z.number()
|
|
8431
|
+
});
|
|
8432
|
+
TerminalSnapshotRequestSchema = import_zod4.z.object({
|
|
8433
|
+
terminal_id: import_zod4.z.string().min(1),
|
|
8434
|
+
snapshot_id: import_zod4.z.string().min(1)
|
|
8435
|
+
});
|
|
8436
|
+
}
|
|
8437
|
+
});
|
|
8438
|
+
|
|
8439
|
+
// src/devices/terminal/flowWindow.ts
|
|
8440
|
+
var FlowWindow;
|
|
8441
|
+
var init_flowWindow = __esm({
|
|
8442
|
+
"src/devices/terminal/flowWindow.ts"() {
|
|
8443
|
+
"use strict";
|
|
8444
|
+
init_cjs_shims();
|
|
8445
|
+
FlowWindow = class {
|
|
8446
|
+
constructor(windowBytes) {
|
|
8447
|
+
this.sentBytes = 0;
|
|
8448
|
+
this.ackedBytes = 0;
|
|
8449
|
+
if (!Number.isFinite(windowBytes) || windowBytes <= 0) {
|
|
8450
|
+
throw new Error("A terminal flow window needs a positive size from welcome");
|
|
8451
|
+
}
|
|
8452
|
+
this.windowBytes = Math.trunc(windowBytes);
|
|
8453
|
+
}
|
|
8454
|
+
/** How many more raw bytes may go out before the browser has to catch up. */
|
|
8455
|
+
remaining() {
|
|
8456
|
+
return Math.max(0, this.windowBytes - (this.sentBytes - this.ackedBytes));
|
|
8457
|
+
}
|
|
8458
|
+
/** False means stop reading the pty. Nothing else is an acceptable response. */
|
|
8459
|
+
isOpen() {
|
|
8460
|
+
return this.remaining() > 0;
|
|
8461
|
+
}
|
|
8462
|
+
recordSent(byteCount) {
|
|
8463
|
+
if (!Number.isFinite(byteCount) || byteCount <= 0) return;
|
|
8464
|
+
this.sentBytes += Math.trunc(byteCount);
|
|
8465
|
+
}
|
|
8466
|
+
/**
|
|
8467
|
+
* Cumulative totals, never increments, so a dropped ack self-heals on the next one.
|
|
8468
|
+
*
|
|
8469
|
+
* A browser is not a trusted input. An ack beyond what was sent is clamped rather than believed,
|
|
8470
|
+
* and one that arrives out of order never moves the counter backwards.
|
|
8471
|
+
*/
|
|
8472
|
+
recordAck(ackBytes) {
|
|
8473
|
+
if (!Number.isFinite(ackBytes)) return;
|
|
8474
|
+
const clamped = Math.min(Math.max(Math.trunc(ackBytes), 0), this.sentBytes);
|
|
8475
|
+
if (clamped > this.ackedBytes) {
|
|
8476
|
+
this.ackedBytes = clamped;
|
|
8477
|
+
}
|
|
8478
|
+
}
|
|
8479
|
+
get sent() {
|
|
8480
|
+
return this.sentBytes;
|
|
8481
|
+
}
|
|
8482
|
+
get acked() {
|
|
8483
|
+
return this.ackedBytes;
|
|
8484
|
+
}
|
|
8485
|
+
get size() {
|
|
8486
|
+
return this.windowBytes;
|
|
8487
|
+
}
|
|
8488
|
+
};
|
|
8489
|
+
}
|
|
8490
|
+
});
|
|
8491
|
+
|
|
8492
|
+
// src/devices/terminal/frameSplitter.ts
|
|
8493
|
+
function rawBundleBytes(maxFrameBytes) {
|
|
8494
|
+
const available = maxFrameBytes - ENVELOPE_OVERHEAD_BYTES;
|
|
8495
|
+
if (available <= 0) {
|
|
8496
|
+
throw new Error("max_frame_bytes leaves no room for a terminal payload");
|
|
8497
|
+
}
|
|
8498
|
+
return Math.floor(available * BASE64_NUMERATOR / BASE64_DENOMINATOR);
|
|
8499
|
+
}
|
|
8500
|
+
var ENVELOPE_OVERHEAD_BYTES, BASE64_NUMERATOR, BASE64_DENOMINATOR, BUNDLE_MILLIS, FrameSplitter;
|
|
8501
|
+
var init_frameSplitter = __esm({
|
|
8502
|
+
"src/devices/terminal/frameSplitter.ts"() {
|
|
8503
|
+
"use strict";
|
|
8504
|
+
init_cjs_shims();
|
|
8505
|
+
ENVELOPE_OVERHEAD_BYTES = 512;
|
|
8506
|
+
BASE64_NUMERATOR = 3;
|
|
8507
|
+
BASE64_DENOMINATOR = 4;
|
|
8508
|
+
BUNDLE_MILLIS = 8;
|
|
8509
|
+
FrameSplitter = class {
|
|
8510
|
+
constructor(emit, bundleBytes, bundleMillis = BUNDLE_MILLIS) {
|
|
8511
|
+
this.emit = emit;
|
|
8512
|
+
this.bundleBytes = bundleBytes;
|
|
8513
|
+
this.bundleMillis = bundleMillis;
|
|
8514
|
+
this.pending = [];
|
|
8515
|
+
this.pendingBytes = 0;
|
|
8516
|
+
if (!Number.isFinite(bundleBytes) || bundleBytes <= 0) {
|
|
8517
|
+
throw new Error("A frame splitter needs a positive bundle size");
|
|
8518
|
+
}
|
|
8519
|
+
}
|
|
8520
|
+
/** A chunk over the cap is split across frames, never truncated. */
|
|
8521
|
+
push(chunk) {
|
|
8522
|
+
if (chunk.length === 0) return;
|
|
8523
|
+
this.pending.push(chunk);
|
|
8524
|
+
this.pendingBytes += chunk.length;
|
|
8525
|
+
while (this.pendingBytes >= this.bundleBytes) {
|
|
8526
|
+
this.emitBytes(this.bundleBytes);
|
|
8527
|
+
}
|
|
8528
|
+
if (this.pendingBytes > 0) {
|
|
8529
|
+
this.arm();
|
|
8530
|
+
} else {
|
|
8531
|
+
this.disarm();
|
|
8532
|
+
}
|
|
8533
|
+
}
|
|
8534
|
+
/** Sends whatever is held, if anything. Used when the terminal closes and by the timer. */
|
|
8535
|
+
flush() {
|
|
8536
|
+
this.disarm();
|
|
8537
|
+
if (this.pendingBytes > 0) {
|
|
8538
|
+
this.emitBytes(this.pendingBytes);
|
|
8539
|
+
}
|
|
8540
|
+
}
|
|
8541
|
+
dispose() {
|
|
8542
|
+
this.disarm();
|
|
8543
|
+
this.pending = [];
|
|
8544
|
+
this.pendingBytes = 0;
|
|
8545
|
+
}
|
|
8546
|
+
get buffered() {
|
|
8547
|
+
return this.pendingBytes;
|
|
8548
|
+
}
|
|
8549
|
+
emitBytes(byteCount) {
|
|
8550
|
+
const joined = Buffer.concat(this.pending, this.pendingBytes);
|
|
8551
|
+
const frame = joined.subarray(0, byteCount);
|
|
8552
|
+
const rest = joined.subarray(byteCount);
|
|
8553
|
+
this.pending = rest.length > 0 ? [rest] : [];
|
|
8554
|
+
this.pendingBytes = rest.length;
|
|
8555
|
+
this.emit(frame);
|
|
8556
|
+
}
|
|
8557
|
+
arm() {
|
|
8558
|
+
if (this.timer !== void 0) return;
|
|
8559
|
+
this.timer = setTimeout(() => {
|
|
8560
|
+
this.timer = void 0;
|
|
8561
|
+
this.flush();
|
|
8562
|
+
}, this.bundleMillis);
|
|
8563
|
+
this.timer.unref?.();
|
|
8564
|
+
}
|
|
8565
|
+
disarm() {
|
|
8566
|
+
if (this.timer === void 0) return;
|
|
8567
|
+
clearTimeout(this.timer);
|
|
8568
|
+
this.timer = void 0;
|
|
8569
|
+
}
|
|
8570
|
+
};
|
|
8571
|
+
}
|
|
8572
|
+
});
|
|
8573
|
+
|
|
8574
|
+
// src/devices/terminal/shellContext.ts
|
|
8575
|
+
function processTableReader(platform2 = process.platform) {
|
|
8576
|
+
if (platform2 === "linux") return readFromProc;
|
|
8577
|
+
if (platform2 === "darwin") return readFromPsAndLsof;
|
|
8578
|
+
return null;
|
|
8579
|
+
}
|
|
8580
|
+
function programName(rawName) {
|
|
8581
|
+
const text = cleanText(rawName, Number.MAX_SAFE_INTEGER);
|
|
8582
|
+
if (text === null) return null;
|
|
8583
|
+
const firstWord = text.split(/\s+/, 1)[0];
|
|
8584
|
+
const baseName = firstWord.slice(firstWord.lastIndexOf("/") + 1).replace(/:+$/, "");
|
|
8585
|
+
return baseName === "" ? null : truncate(baseName, MAX_COMMAND_CHARACTERS);
|
|
8586
|
+
}
|
|
8587
|
+
function displayCwd(rawCwd, resolvedHome) {
|
|
8588
|
+
const cwd = cleanText(rawCwd, Number.MAX_SAFE_INTEGER);
|
|
8589
|
+
if (cwd === null) return null;
|
|
8590
|
+
let shown = cwd;
|
|
8591
|
+
if (resolvedHome && cwd === resolvedHome) {
|
|
8592
|
+
shown = HOME_PREFIX;
|
|
8593
|
+
} else if (resolvedHome && cwd.startsWith(resolvedHome + "/")) {
|
|
8594
|
+
shown = HOME_PREFIX + cwd.slice(resolvedHome.length);
|
|
8595
|
+
}
|
|
8596
|
+
return truncate(shown, MAX_CWD_CHARACTERS);
|
|
8597
|
+
}
|
|
8598
|
+
function resolvedHomeDirectory() {
|
|
8599
|
+
try {
|
|
8600
|
+
return import_fs9.default.realpathSync(import_os5.default.homedir());
|
|
8601
|
+
} catch {
|
|
8602
|
+
return null;
|
|
8603
|
+
}
|
|
8604
|
+
}
|
|
8605
|
+
function cleanText(raw, maxCharacters) {
|
|
8606
|
+
if (raw === null || raw === void 0) return null;
|
|
8607
|
+
const text = raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").trim();
|
|
8608
|
+
return text === "" ? null : truncate(text, maxCharacters);
|
|
8609
|
+
}
|
|
8610
|
+
function truncate(text, maxCharacters) {
|
|
8611
|
+
const codePoints = Array.from(text);
|
|
8612
|
+
return codePoints.length <= maxCharacters ? text : codePoints.slice(0, maxCharacters).join("");
|
|
8613
|
+
}
|
|
8614
|
+
async function readFromProc(shellPids) {
|
|
8615
|
+
const contexts = /* @__PURE__ */ new Map();
|
|
8616
|
+
await Promise.all(shellPids.map(async (shellPid) => {
|
|
8617
|
+
const groups = await readProcGroups(shellPid);
|
|
8618
|
+
if (!groups) return;
|
|
8619
|
+
const cwd = await readOrNull(() => import_fs9.default.promises.readlink(`/proc/${shellPid}/cwd`));
|
|
8620
|
+
const foregroundName = groups.foregroundGroupId === groups.shellGroupId ? null : await readOrNull(() => import_fs9.default.promises.readFile(`/proc/${groups.foregroundGroupId}/comm`, "utf8"));
|
|
8621
|
+
contexts.set(shellPid, { cwd, foregroundName });
|
|
8622
|
+
}));
|
|
8623
|
+
return contexts;
|
|
8624
|
+
}
|
|
8625
|
+
async function readProcGroups(shellPid) {
|
|
8626
|
+
const stat = await readOrNull(() => import_fs9.default.promises.readFile(`/proc/${shellPid}/stat`, "utf8"));
|
|
8627
|
+
if (stat === null) return null;
|
|
8628
|
+
const afterName = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
8629
|
+
const shellGroupId = Number.parseInt(afterName[2], 10);
|
|
8630
|
+
const foregroundGroupId = Number.parseInt(afterName[5], 10);
|
|
8631
|
+
if (!(shellGroupId > 0) || !(foregroundGroupId > 0)) return null;
|
|
8632
|
+
return { shellGroupId, foregroundGroupId };
|
|
8633
|
+
}
|
|
8634
|
+
async function readFromPsAndLsof(shellPids) {
|
|
8635
|
+
const contexts = /* @__PURE__ */ new Map();
|
|
8636
|
+
if (shellPids.length === 0) return contexts;
|
|
8637
|
+
const groups = parseColumns(await run("ps", ["-o", "pid=,pgid=,tpgid=", "-p", shellPids.join(",")]));
|
|
8638
|
+
const foregroundGroupIds = [...groups.values()].filter(([shellGroupId, foregroundGroupId]) => foregroundGroupId > 0 && foregroundGroupId !== shellGroupId).map(([, foregroundGroupId]) => foregroundGroupId);
|
|
8639
|
+
const [names, cwds] = await Promise.all([
|
|
8640
|
+
foregroundGroupIds.length === 0 ? Promise.resolve(/* @__PURE__ */ new Map()) : run("ps", ["-o", "pid=,comm=", "-p", foregroundGroupIds.join(",")]).then(parseNames),
|
|
8641
|
+
run("lsof", ["-a", "-d", "cwd", "-Fpn", "-p", shellPids.join(",")]).then(parseLsofCwds)
|
|
8642
|
+
]);
|
|
8643
|
+
for (const shellPid of shellPids) {
|
|
8644
|
+
const group = groups.get(shellPid);
|
|
8645
|
+
if (!group) continue;
|
|
8646
|
+
const [shellGroupId, foregroundGroupId] = group;
|
|
8647
|
+
contexts.set(shellPid, {
|
|
8648
|
+
cwd: cwds.get(shellPid) ?? null,
|
|
8649
|
+
foregroundName: foregroundGroupId === shellGroupId ? null : names.get(foregroundGroupId) ?? null
|
|
8650
|
+
});
|
|
8651
|
+
}
|
|
8652
|
+
return contexts;
|
|
8653
|
+
}
|
|
8654
|
+
function parseColumns(stdout) {
|
|
8655
|
+
const columns = /* @__PURE__ */ new Map();
|
|
8656
|
+
for (const line of (stdout ?? "").split("\n")) {
|
|
8657
|
+
const [pid, groupId, foregroundGroupId] = line.trim().split(/\s+/).map((field) => Number.parseInt(field, 10));
|
|
8658
|
+
if (pid > 0 && groupId > 0) columns.set(pid, [groupId, foregroundGroupId]);
|
|
8659
|
+
}
|
|
8660
|
+
return columns;
|
|
8661
|
+
}
|
|
8662
|
+
function parseNames(stdout) {
|
|
8663
|
+
const names = /* @__PURE__ */ new Map();
|
|
8664
|
+
for (const line of (stdout ?? "").split("\n")) {
|
|
8665
|
+
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
8666
|
+
if (match) names.set(Number.parseInt(match[1], 10), match[2]);
|
|
8667
|
+
}
|
|
8668
|
+
return names;
|
|
8669
|
+
}
|
|
8670
|
+
function parseLsofCwds(stdout) {
|
|
8671
|
+
const cwds = /* @__PURE__ */ new Map();
|
|
8672
|
+
let pid = 0;
|
|
8673
|
+
for (const line of (stdout ?? "").split("\n")) {
|
|
8674
|
+
if (line.startsWith("p")) pid = Number.parseInt(line.slice(1), 10);
|
|
8675
|
+
else if (line.startsWith("n") && pid > 0) cwds.set(pid, line.slice(1));
|
|
8676
|
+
}
|
|
8677
|
+
return cwds;
|
|
8678
|
+
}
|
|
8679
|
+
function run(program, args) {
|
|
8680
|
+
return new Promise((resolve) => {
|
|
8681
|
+
(0, import_child_process2.execFile)(program, args, { timeout: PROCESS_READ_TIMEOUT_MILLIS }, (err, stdout) => {
|
|
8682
|
+
const output = String(stdout ?? "");
|
|
8683
|
+
resolve(err && output === "" ? null : output);
|
|
8684
|
+
});
|
|
8685
|
+
});
|
|
8686
|
+
}
|
|
8687
|
+
async function readOrNull(read) {
|
|
8688
|
+
try {
|
|
8689
|
+
return await read();
|
|
8690
|
+
} catch {
|
|
8691
|
+
return null;
|
|
8692
|
+
}
|
|
8693
|
+
}
|
|
8694
|
+
var import_child_process2, import_fs9, import_os5, SHELL_CONTEXT_POLL_MILLIS, MAX_CWD_CHARACTERS, MAX_COMMAND_CHARACTERS, PROCESS_READ_TIMEOUT_MILLIS, HOME_PREFIX, ShellContextTracker;
|
|
8695
|
+
var init_shellContext = __esm({
|
|
8696
|
+
"src/devices/terminal/shellContext.ts"() {
|
|
8697
|
+
"use strict";
|
|
8698
|
+
init_cjs_shims();
|
|
8699
|
+
import_child_process2 = require("child_process");
|
|
8700
|
+
import_fs9 = __toESM(require("fs"), 1);
|
|
8701
|
+
import_os5 = __toESM(require("os"), 1);
|
|
8702
|
+
SHELL_CONTEXT_POLL_MILLIS = 5e3;
|
|
8703
|
+
MAX_CWD_CHARACTERS = 1024;
|
|
8704
|
+
MAX_COMMAND_CHARACTERS = 64;
|
|
8705
|
+
PROCESS_READ_TIMEOUT_MILLIS = 2e3;
|
|
8706
|
+
HOME_PREFIX = "~";
|
|
8707
|
+
ShellContextTracker = class {
|
|
8708
|
+
constructor() {
|
|
8709
|
+
this.known = /* @__PURE__ */ new Map();
|
|
8710
|
+
}
|
|
8711
|
+
/** The event to send, or null when nothing changed since the last one. */
|
|
8712
|
+
observe(terminalId, raw, resolvedHome) {
|
|
8713
|
+
const previous = this.known.get(terminalId);
|
|
8714
|
+
const command = programName(raw.foregroundName);
|
|
8715
|
+
const previousCommand = previous?.command ?? null;
|
|
8716
|
+
const lastCommand = previousCommand !== null && previousCommand !== command ? previousCommand : previous?.last_command ?? null;
|
|
8717
|
+
const next = {
|
|
8718
|
+
terminal_id: terminalId,
|
|
8719
|
+
cwd: displayCwd(raw.cwd, resolvedHome),
|
|
8720
|
+
command,
|
|
8721
|
+
last_command: lastCommand
|
|
8722
|
+
};
|
|
8723
|
+
if (previous && previous.cwd === next.cwd && previous.command === next.command && previous.last_command === next.last_command) {
|
|
8724
|
+
return null;
|
|
8725
|
+
}
|
|
8726
|
+
this.known.set(terminalId, next);
|
|
8727
|
+
return next;
|
|
8728
|
+
}
|
|
8729
|
+
/** What was last sent for every shell, to send again after a reconnect. */
|
|
8730
|
+
current() {
|
|
8731
|
+
return [...this.known.values()];
|
|
8732
|
+
}
|
|
8733
|
+
forget(terminalId) {
|
|
8734
|
+
this.known.delete(terminalId);
|
|
8735
|
+
}
|
|
8736
|
+
clear() {
|
|
8737
|
+
this.known.clear();
|
|
8738
|
+
}
|
|
8739
|
+
};
|
|
8740
|
+
}
|
|
8741
|
+
});
|
|
8742
|
+
|
|
8743
|
+
// src/devices/terminal/screenMirror.ts
|
|
8744
|
+
function loadModules() {
|
|
8745
|
+
if (loadedModules !== void 0) return loadedModules;
|
|
8746
|
+
try {
|
|
8747
|
+
const headless = require2("@xterm/headless");
|
|
8748
|
+
const serialize = require2("@xterm/addon-serialize");
|
|
8749
|
+
loadedModules = { Terminal: headless.Terminal, SerializeAddon: serialize.SerializeAddon };
|
|
8750
|
+
} catch (err) {
|
|
8751
|
+
logger.warn("Screen restore unavailable: the headless terminal did not load", { error: errorName(err) });
|
|
8752
|
+
loadedModules = null;
|
|
8753
|
+
}
|
|
8754
|
+
return loadedModules;
|
|
8755
|
+
}
|
|
8756
|
+
function isScreenMirrorSupported() {
|
|
8757
|
+
return loadModules() !== null;
|
|
8758
|
+
}
|
|
8759
|
+
function errorName(err) {
|
|
8760
|
+
return err instanceof Error ? err.name : typeof err;
|
|
8761
|
+
}
|
|
8762
|
+
var import_module, SCROLLBACK_LINES, MAX_SNAPSHOT_BYTES, SCROLLBACK_LINES_TO_TRY, require2, loadedModules, ScreenMirror;
|
|
8763
|
+
var init_screenMirror = __esm({
|
|
8764
|
+
"src/devices/terminal/screenMirror.ts"() {
|
|
8765
|
+
"use strict";
|
|
8766
|
+
init_cjs_shims();
|
|
8767
|
+
import_module = require("module");
|
|
8768
|
+
init_logger();
|
|
8769
|
+
SCROLLBACK_LINES = 1e3;
|
|
8770
|
+
MAX_SNAPSHOT_BYTES = 1024 * 1024;
|
|
8771
|
+
SCROLLBACK_LINES_TO_TRY = [SCROLLBACK_LINES, 200, 0];
|
|
8772
|
+
require2 = (0, import_module.createRequire)(importMetaUrl);
|
|
8773
|
+
ScreenMirror = class _ScreenMirror {
|
|
8774
|
+
constructor(terminal, serializer) {
|
|
8775
|
+
this.terminal = terminal;
|
|
8776
|
+
this.serializer = serializer;
|
|
8777
|
+
this.disposed = false;
|
|
8778
|
+
}
|
|
8779
|
+
/** Null when the headless terminal cannot load. The shell then works as before, without restore. */
|
|
8780
|
+
static create(cols, rows) {
|
|
8781
|
+
const modules = loadModules();
|
|
8782
|
+
if (!modules) return null;
|
|
8783
|
+
const terminal = new modules.Terminal({ cols, rows, scrollback: SCROLLBACK_LINES, allowProposedApi: true });
|
|
8784
|
+
const serializer = new modules.SerializeAddon();
|
|
8785
|
+
terminal.loadAddon(serializer);
|
|
8786
|
+
return new _ScreenMirror(terminal, serializer);
|
|
8787
|
+
}
|
|
8788
|
+
/** Bytes exactly as they were sent. xterm parses them later, in order, and joins split characters. */
|
|
8789
|
+
feed(bytes) {
|
|
8790
|
+
if (!this.disposed) this.terminal.write(bytes);
|
|
8791
|
+
}
|
|
8792
|
+
resize(cols, rows) {
|
|
8793
|
+
if (!this.disposed) this.terminal.resize(cols, rows);
|
|
8794
|
+
}
|
|
8795
|
+
/**
|
|
8796
|
+
* The screen after everything fed before this call, and nothing fed after it. Null when the
|
|
8797
|
+
* mirror was disposed first.
|
|
8798
|
+
*
|
|
8799
|
+
* xterm parses writes in batches, on a timer. An empty write queued now has its callback run
|
|
8800
|
+
* after every earlier chunk is parsed and before the next one is, so serialising inside it sees
|
|
8801
|
+
* exactly what was fed before this call.
|
|
8802
|
+
*/
|
|
8803
|
+
snapshot() {
|
|
8804
|
+
return new Promise((resolve) => {
|
|
8805
|
+
if (this.disposed) {
|
|
8806
|
+
resolve(null);
|
|
8807
|
+
return;
|
|
8808
|
+
}
|
|
8809
|
+
this.terminal.write("", () => resolve(this.disposed ? null : this.serialise()));
|
|
8810
|
+
});
|
|
8811
|
+
}
|
|
8812
|
+
dispose() {
|
|
8813
|
+
if (this.disposed) return;
|
|
8814
|
+
this.disposed = true;
|
|
8815
|
+
this.terminal.dispose();
|
|
8816
|
+
}
|
|
8817
|
+
/** Null when even the screen alone is over the cap. The tab then waits out its fallback. */
|
|
8818
|
+
serialise() {
|
|
8819
|
+
for (const scrollback of SCROLLBACK_LINES_TO_TRY) {
|
|
8820
|
+
const text = this.serializer.serialize({ scrollback });
|
|
8821
|
+
if (Buffer.byteLength(text, "utf-8") <= MAX_SNAPSHOT_BYTES) {
|
|
8822
|
+
return { text, cols: this.terminal.cols, rows: this.terminal.rows };
|
|
8823
|
+
}
|
|
8824
|
+
}
|
|
8825
|
+
return null;
|
|
8826
|
+
}
|
|
8827
|
+
};
|
|
8828
|
+
}
|
|
8829
|
+
});
|
|
8830
|
+
|
|
8831
|
+
// src/devices/terminal/terminalHandler.ts
|
|
8832
|
+
function positiveMillis(seconds) {
|
|
8833
|
+
return seconds !== void 0 && Number.isFinite(seconds) && seconds > 0 ? seconds * MILLIS_PER_SECOND : void 0;
|
|
8834
|
+
}
|
|
8835
|
+
function splitIntoParts(bytes, partBytes) {
|
|
8836
|
+
const parts = [];
|
|
8837
|
+
for (let offset = 0; offset < bytes.length; offset += partBytes) {
|
|
8838
|
+
parts.push(bytes.subarray(offset, offset + partBytes));
|
|
8839
|
+
}
|
|
8840
|
+
return parts.length > 0 ? parts : [Buffer.alloc(0)];
|
|
8841
|
+
}
|
|
8842
|
+
function errorName2(err) {
|
|
8843
|
+
return err instanceof Error ? err.name : typeof err;
|
|
8844
|
+
}
|
|
8845
|
+
function signalName(signal) {
|
|
8846
|
+
for (const [name, number] of Object.entries(import_os6.default.constants.signals)) {
|
|
8847
|
+
if (number === signal) return name.replace(/^SIG/, "");
|
|
8848
|
+
}
|
|
8849
|
+
return String(signal);
|
|
8850
|
+
}
|
|
8851
|
+
var import_os6, DEFAULT_COLS, DEFAULT_ROWS, EXPIRY_CHECK_MILLIS, MILLIS_PER_SECOND, TerminalHandler;
|
|
8852
|
+
var init_terminalHandler = __esm({
|
|
8853
|
+
"src/devices/terminal/terminalHandler.ts"() {
|
|
8854
|
+
"use strict";
|
|
8855
|
+
init_cjs_shims();
|
|
8856
|
+
import_os6 = __toESM(require("os"), 1);
|
|
8857
|
+
init_logger();
|
|
8858
|
+
init_envelope();
|
|
8859
|
+
init_terminal_schema();
|
|
8860
|
+
init_flowWindow();
|
|
8861
|
+
init_frameSplitter();
|
|
8862
|
+
init_shellContext();
|
|
8863
|
+
init_screenMirror();
|
|
8864
|
+
DEFAULT_COLS = 80;
|
|
8865
|
+
DEFAULT_ROWS = 24;
|
|
8866
|
+
EXPIRY_CHECK_MILLIS = 5e3;
|
|
8867
|
+
MILLIS_PER_SECOND = 1e3;
|
|
8868
|
+
TerminalHandler = class {
|
|
8869
|
+
constructor(dependencies) {
|
|
8870
|
+
this.dependencies = dependencies;
|
|
8871
|
+
this.enabled = true;
|
|
8872
|
+
this.suspended = false;
|
|
8873
|
+
this.expiryTimer = null;
|
|
8874
|
+
this.contextTimer = null;
|
|
8875
|
+
this.contextPollInFlight = false;
|
|
8876
|
+
this.contextTracker = new ShellContextTracker();
|
|
8877
|
+
this.streams = /* @__PURE__ */ new Map();
|
|
8878
|
+
}
|
|
8879
|
+
/**
|
|
8880
|
+
* From welcome. An older dashboard sends no terminal fields, and `terminal_enabled` and the
|
|
8881
|
+
* per-device ceiling keep their defaults.
|
|
8882
|
+
*
|
|
8883
|
+
* The window has no default. A compiled-in one keeps working after the dashboard stops sending
|
|
8884
|
+
* the field, and nobody notices until a node falls over, so an agent without one refuses to open
|
|
8885
|
+
* a terminal at all.
|
|
8886
|
+
*/
|
|
8887
|
+
configure(terminalEnabled, maxTerminalsPerDevice, terminalWindowBytes, maxFrameBytes, idleTimeoutSeconds, maxSessionSeconds) {
|
|
8888
|
+
if (terminalEnabled !== void 0) this.enabled = terminalEnabled;
|
|
8889
|
+
if (maxTerminalsPerDevice !== void 0) this.dependencies.registry.setMaxTerminals(maxTerminalsPerDevice);
|
|
8890
|
+
this.windowBytes = terminalWindowBytes;
|
|
8891
|
+
this.bundleBytes = rawBundleBytes(maxFrameBytes);
|
|
8892
|
+
if (terminalWindowBytes === void 0) {
|
|
8893
|
+
logger.info("Terminals unavailable: welcome carried no terminal_window_bytes");
|
|
8894
|
+
}
|
|
8895
|
+
this.idleTimeoutMillis = positiveMillis(idleTimeoutSeconds);
|
|
8896
|
+
this.maxSessionMillis = positiveMillis(maxSessionSeconds);
|
|
8897
|
+
this.startExpiryTimer();
|
|
8898
|
+
}
|
|
8899
|
+
/**
|
|
8900
|
+
* Ends every shell past its idle timeout or its max-session cap. Runs on a timer; public so a test
|
|
8901
|
+
* decides when. The cap goes first: a shell both idle and too old is closed for the rule typing
|
|
8902
|
+
* cannot reset.
|
|
8903
|
+
*/
|
|
8904
|
+
expireShells() {
|
|
8905
|
+
if (this.suspended) return;
|
|
8906
|
+
const nowMillis = this.nowMillis();
|
|
8907
|
+
for (const [terminalId, stream] of [...this.streams.entries()]) {
|
|
8908
|
+
if (this.maxSessionMillis !== void 0 && nowMillis - stream.openedAtMillis >= this.maxSessionMillis) {
|
|
8909
|
+
this.expire(terminalId, CLOSE_REASON_MAX_SESSION);
|
|
8910
|
+
} else if (this.idleTimeoutMillis !== void 0 && nowMillis - stream.lastActivityMillis >= this.idleTimeoutMillis) {
|
|
8911
|
+
this.expire(terminalId, CLOSE_REASON_IDLE_TIMEOUT);
|
|
8912
|
+
}
|
|
8913
|
+
}
|
|
8914
|
+
}
|
|
8915
|
+
handle(envelope) {
|
|
8916
|
+
if (envelope.op === OP_OPEN) {
|
|
8917
|
+
this.open(envelope);
|
|
8918
|
+
} else if (envelope.op === OP_CLOSE) {
|
|
8919
|
+
this.closeFromDashboard(envelope);
|
|
8920
|
+
} else if (envelope.op === OP_ACK) {
|
|
8921
|
+
this.acknowledge(envelope);
|
|
8922
|
+
} else if (envelope.op === OP_RESIZE) {
|
|
8923
|
+
this.resize(envelope);
|
|
8924
|
+
} else if (envelope.op === OP_DATA) {
|
|
8925
|
+
this.input(envelope);
|
|
8926
|
+
} else if (envelope.op === OP_SIGNAL) {
|
|
8927
|
+
this.signal(envelope);
|
|
8928
|
+
} else if (envelope.op === OP_SNAPSHOT) {
|
|
8929
|
+
void this.snapshot(envelope);
|
|
8930
|
+
} else {
|
|
8931
|
+
logger.debug("Ignoring unhandled terminal op", { op: envelope.op });
|
|
8932
|
+
}
|
|
8933
|
+
}
|
|
8934
|
+
/**
|
|
8935
|
+
* The agent is stopping: interrupted, revoked, or refused for good. Every shell dies with it.
|
|
8936
|
+
* A dropped socket is not this; it is `suspend`.
|
|
8937
|
+
*/
|
|
8938
|
+
closeAll() {
|
|
8939
|
+
this.stopExpiryTimer();
|
|
8940
|
+
this.stopContextTimer();
|
|
8941
|
+
this.contextTracker.clear();
|
|
8942
|
+
this.suspended = false;
|
|
8943
|
+
for (const stream of this.streams.values()) {
|
|
8944
|
+
stream.splitter.dispose();
|
|
8945
|
+
stream.mirror?.dispose();
|
|
8946
|
+
}
|
|
8947
|
+
this.streams.clear();
|
|
8948
|
+
this.dependencies.registry.killAll();
|
|
8949
|
+
}
|
|
8950
|
+
/**
|
|
8951
|
+
* The socket dropped and the agent will redial. Every shell stops being read and keeps running.
|
|
8952
|
+
* Whatever it prints waits in the kernel, and the shell blocks once that buffer is full.
|
|
8953
|
+
*/
|
|
8954
|
+
suspend() {
|
|
8955
|
+
if (this.suspended) return;
|
|
8956
|
+
this.suspended = true;
|
|
8957
|
+
for (const [terminalId, session] of this.dependencies.registry.entries()) {
|
|
8958
|
+
try {
|
|
8959
|
+
session.pause();
|
|
8960
|
+
} catch (err) {
|
|
8961
|
+
logger.warn("Terminal pause failed", { terminal_id: terminalId, error: errorName2(err) });
|
|
8962
|
+
}
|
|
8963
|
+
}
|
|
8964
|
+
logger.info("Terminals suspended until the dashboard is back", { count: this.dependencies.registry.size() });
|
|
8965
|
+
}
|
|
8966
|
+
/**
|
|
8967
|
+
* `welcome` arrived. Every shell the dashboard did not close is read again, except one whose
|
|
8968
|
+
* window is still full. That one resumes on the `ack` that reopens it.
|
|
8969
|
+
*/
|
|
8970
|
+
resumeAll() {
|
|
8971
|
+
if (!this.suspended) return;
|
|
8972
|
+
this.suspended = false;
|
|
8973
|
+
for (const [terminalId, session] of this.dependencies.registry.entries()) {
|
|
8974
|
+
const stream = this.streams.get(terminalId);
|
|
8975
|
+
if (stream) {
|
|
8976
|
+
for (const chunk of stream.heldBundles.splice(0)) {
|
|
8977
|
+
this.sendData(terminalId, chunk);
|
|
8978
|
+
}
|
|
8979
|
+
}
|
|
8980
|
+
if (stream?.paused) continue;
|
|
8981
|
+
try {
|
|
8982
|
+
session.resume();
|
|
8983
|
+
} catch (err) {
|
|
8984
|
+
logger.warn("Terminal resume failed", { terminal_id: terminalId, error: errorName2(err) });
|
|
8985
|
+
}
|
|
8986
|
+
}
|
|
8987
|
+
for (const context of this.contextTracker.current()) {
|
|
8988
|
+
if (this.streams.has(context.terminal_id)) {
|
|
8989
|
+
this.dependencies.send(event(CHANNEL_TERMINAL, OP_CONTEXT, context));
|
|
8990
|
+
}
|
|
8991
|
+
}
|
|
8992
|
+
}
|
|
8993
|
+
/**
|
|
8994
|
+
* Reads every open shell's directory and foreground program, and sends `context` for each that
|
|
8995
|
+
* changed. Runs on a timer while any shell is open; public so a test decides when. Never throws,
|
|
8996
|
+
* and never moves a shell's idle clock.
|
|
8997
|
+
*/
|
|
8998
|
+
async pollShellContexts() {
|
|
8999
|
+
const read = this.dependencies.readShellContexts;
|
|
9000
|
+
if (!read || this.suspended || this.contextPollInFlight || this.streams.size === 0) return;
|
|
9001
|
+
this.contextPollInFlight = true;
|
|
9002
|
+
try {
|
|
9003
|
+
const terminalIdsByPid = /* @__PURE__ */ new Map();
|
|
9004
|
+
for (const [terminalId, stream] of this.streams) {
|
|
9005
|
+
terminalIdsByPid.set(stream.session.pid, terminalId);
|
|
9006
|
+
}
|
|
9007
|
+
const contexts = await read([...terminalIdsByPid.keys()]);
|
|
9008
|
+
if (this.suspended) return;
|
|
9009
|
+
for (const [shellPid, raw] of contexts) {
|
|
9010
|
+
const terminalId = terminalIdsByPid.get(shellPid);
|
|
9011
|
+
if (!terminalId || !this.streams.has(terminalId)) continue;
|
|
9012
|
+
const changed = this.contextTracker.observe(terminalId, raw, this.dependencies.resolvedHome ?? null);
|
|
9013
|
+
if (changed) this.dependencies.send(event(CHANNEL_TERMINAL, OP_CONTEXT, changed));
|
|
9014
|
+
}
|
|
9015
|
+
} catch (err) {
|
|
9016
|
+
logger.debug("Shell context poll failed", { error: errorName2(err) });
|
|
9017
|
+
} finally {
|
|
9018
|
+
this.contextPollInFlight = false;
|
|
9019
|
+
}
|
|
9020
|
+
}
|
|
9021
|
+
/** What `hello` lists: every shell still held, so the dashboard can keep it. */
|
|
9022
|
+
held() {
|
|
9023
|
+
return this.dependencies.registry.entries().map(([terminalId, session]) => ({
|
|
9024
|
+
terminal_id: terminalId,
|
|
9025
|
+
pid: session.pid,
|
|
9026
|
+
shell: session.shell,
|
|
9027
|
+
cols: session.cols,
|
|
9028
|
+
rows: session.rows
|
|
9029
|
+
}));
|
|
9030
|
+
}
|
|
9031
|
+
open(envelope) {
|
|
9032
|
+
const parsed = TerminalOpenSchema.safeParse(envelope.payload);
|
|
9033
|
+
if (!parsed.success) {
|
|
9034
|
+
this.refuse(envelope, null, ERROR_INVALID_PAYLOAD2, "open payload could not be read.");
|
|
9035
|
+
return;
|
|
9036
|
+
}
|
|
9037
|
+
const request2 = parsed.data;
|
|
9038
|
+
const { registry } = this.dependencies;
|
|
9039
|
+
if (!this.enabled || this.windowBytes === void 0 || this.bundleBytes === void 0) {
|
|
9040
|
+
this.refuse(envelope, request2.terminal_id, ERROR_TERMINAL_DISABLED, "Terminals are disabled.");
|
|
9041
|
+
return;
|
|
9042
|
+
}
|
|
9043
|
+
if (registry.has(request2.terminal_id) || registry.isFull()) {
|
|
9044
|
+
this.refuse(
|
|
9045
|
+
envelope,
|
|
9046
|
+
request2.terminal_id,
|
|
9047
|
+
ERROR_TERMINAL_LIMIT_REACHED,
|
|
9048
|
+
"Too many terminals are already open on this machine."
|
|
9049
|
+
);
|
|
9050
|
+
return;
|
|
9051
|
+
}
|
|
9052
|
+
const resolution = this.dependencies.resolveShell(request2.shell);
|
|
9053
|
+
if ("error" in resolution) {
|
|
9054
|
+
this.refuse(envelope, request2.terminal_id, ERROR_SHELL_NOT_ALLOWED, resolution.error);
|
|
9055
|
+
return;
|
|
9056
|
+
}
|
|
9057
|
+
const cols = clampGrid(request2.cols, DEFAULT_COLS);
|
|
9058
|
+
const rows = clampGrid(request2.rows, DEFAULT_ROWS);
|
|
9059
|
+
let session;
|
|
9060
|
+
try {
|
|
9061
|
+
session = this.dependencies.spawn({ shell: resolution.shell, cwd: request2.cwd, cols, rows });
|
|
9062
|
+
} catch (err) {
|
|
9063
|
+
logger.warn("Terminal spawn failed", { terminal_id: request2.terminal_id, error: errorName2(err) });
|
|
9064
|
+
this.refuse(envelope, request2.terminal_id, ERROR_SPAWN_FAILED, "The shell could not be started.");
|
|
9065
|
+
return;
|
|
9066
|
+
}
|
|
9067
|
+
if (!registry.add(request2.terminal_id, session)) {
|
|
9068
|
+
session.kill();
|
|
9069
|
+
this.refuse(
|
|
9070
|
+
envelope,
|
|
9071
|
+
request2.terminal_id,
|
|
9072
|
+
ERROR_TERMINAL_LIMIT_REACHED,
|
|
9073
|
+
"Too many terminals are already open on this machine."
|
|
9074
|
+
);
|
|
9075
|
+
return;
|
|
9076
|
+
}
|
|
9077
|
+
this.startStream(request2.terminal_id, session, this.windowBytes, this.bundleBytes);
|
|
9078
|
+
logger.info("Terminal opened", { terminal_id: request2.terminal_id, pid: session.pid });
|
|
9079
|
+
const opened = {
|
|
9080
|
+
terminal_id: request2.terminal_id,
|
|
9081
|
+
pid: session.pid,
|
|
9082
|
+
shell: session.shell,
|
|
9083
|
+
cols,
|
|
9084
|
+
rows
|
|
9085
|
+
};
|
|
9086
|
+
this.dependencies.send(response(envelope, OP_OPENED, opened));
|
|
9087
|
+
}
|
|
9088
|
+
startStream(terminalId, session, windowBytes, bundleBytes) {
|
|
9089
|
+
const openedAtMillis = this.nowMillis();
|
|
9090
|
+
const stream = {
|
|
9091
|
+
session,
|
|
9092
|
+
window: new FlowWindow(windowBytes),
|
|
9093
|
+
splitter: new FrameSplitter((chunk) => this.sendData(terminalId, chunk), bundleBytes),
|
|
9094
|
+
mirror: ScreenMirror.create(session.cols, session.rows),
|
|
9095
|
+
paused: false,
|
|
9096
|
+
sentSeq: 0,
|
|
9097
|
+
heldBundles: [],
|
|
9098
|
+
openedAtMillis,
|
|
9099
|
+
lastActivityMillis: openedAtMillis
|
|
9100
|
+
};
|
|
9101
|
+
this.streams.set(terminalId, stream);
|
|
9102
|
+
this.startContextTimer();
|
|
9103
|
+
session.onData((chunk) => {
|
|
9104
|
+
stream.lastActivityMillis = this.nowMillis();
|
|
9105
|
+
stream.splitter.push(chunk);
|
|
9106
|
+
});
|
|
9107
|
+
session.onExit((exitCode, signal) => {
|
|
9108
|
+
if (this.dependencies.registry.remove(terminalId) === void 0) return;
|
|
9109
|
+
stream.splitter.flush();
|
|
9110
|
+
stream.splitter.dispose();
|
|
9111
|
+
this.forgetStream(terminalId);
|
|
9112
|
+
logger.info("Terminal shell exited", { terminal_id: terminalId, exit_code: exitCode, signal });
|
|
9113
|
+
const exit = signal ? { terminal_id: terminalId, exit_code: null, signal: signalName(signal) } : { terminal_id: terminalId, exit_code: exitCode, signal: null };
|
|
9114
|
+
this.dependencies.send(event(CHANNEL_TERMINAL, OP_EXIT, exit));
|
|
9115
|
+
this.dependencies.send(event(
|
|
9116
|
+
CHANNEL_TERMINAL,
|
|
9117
|
+
OP_CLOSE,
|
|
9118
|
+
{ terminal_id: terminalId, reason: CLOSE_REASON_USER_CLOSED }
|
|
9119
|
+
));
|
|
9120
|
+
});
|
|
9121
|
+
}
|
|
9122
|
+
/**
|
|
9123
|
+
* 1 bundle leaves, and the window narrows by what it carried.
|
|
9124
|
+
*
|
|
9125
|
+
* The pause happens after the send rather than before. These bytes are already out of the pty,
|
|
9126
|
+
* and holding them is the buffering this whole mechanism exists to avoid.
|
|
9127
|
+
*
|
|
9128
|
+
* While the socket is down the bundle is held instead, and neither counts against the window nor
|
|
9129
|
+
* takes a seq. Sent into no socket, it would be a seq the dashboard never sees and bytes nobody
|
|
9130
|
+
* can ack, and the window would never reopen.
|
|
9131
|
+
*
|
|
9132
|
+
* The mirror is fed here, in the same tick as the seq, and nowhere else. A snapshot then holds
|
|
9133
|
+
* exactly the bundles up to its `after_seq`. Fed when the pty is read, a bundle still being cut
|
|
9134
|
+
* would be in the screen and drawn a second time when it is sent.
|
|
9135
|
+
*/
|
|
9136
|
+
sendData(terminalId, chunk) {
|
|
9137
|
+
const stream = this.streams.get(terminalId);
|
|
9138
|
+
if (!stream) return;
|
|
9139
|
+
if (this.suspended) {
|
|
9140
|
+
stream.heldBundles.push(chunk);
|
|
9141
|
+
return;
|
|
9142
|
+
}
|
|
9143
|
+
stream.sentSeq += 1;
|
|
9144
|
+
stream.mirror?.feed(chunk);
|
|
9145
|
+
const data = { terminal_id: terminalId, data: chunk.toString("base64") };
|
|
9146
|
+
this.dependencies.send({ ...event(CHANNEL_TERMINAL, OP_DATA, data), seq: stream.sentSeq });
|
|
9147
|
+
stream.window.recordSent(chunk.length);
|
|
9148
|
+
if (!stream.window.isOpen() && !stream.paused) {
|
|
9149
|
+
stream.paused = true;
|
|
9150
|
+
stream.session.pause();
|
|
9151
|
+
logger.debug("Terminal window full, reads paused", { terminal_id: terminalId });
|
|
9152
|
+
}
|
|
9153
|
+
}
|
|
9154
|
+
acknowledge(envelope) {
|
|
9155
|
+
const parsed = TerminalAckSchema.safeParse(envelope.payload);
|
|
9156
|
+
if (!parsed.success) return;
|
|
9157
|
+
const stream = this.streams.get(parsed.data.terminal_id);
|
|
9158
|
+
if (!stream) return;
|
|
9159
|
+
stream.window.recordAck(parsed.data.ack_bytes);
|
|
9160
|
+
if (stream.paused && stream.window.isOpen()) {
|
|
9161
|
+
stream.paused = false;
|
|
9162
|
+
if (this.suspended) return;
|
|
9163
|
+
stream.session.resume();
|
|
9164
|
+
logger.debug("Terminal window reopened, reads resumed", { terminal_id: parsed.data.terminal_id });
|
|
9165
|
+
}
|
|
9166
|
+
}
|
|
9167
|
+
closeFromDashboard(envelope) {
|
|
9168
|
+
const parsed = TerminalCloseSchema.safeParse(envelope.payload);
|
|
9169
|
+
if (!parsed.success) return;
|
|
9170
|
+
const session = this.dependencies.registry.remove(parsed.data.terminal_id);
|
|
9171
|
+
if (!session) return;
|
|
9172
|
+
const stream = this.streams.get(parsed.data.terminal_id);
|
|
9173
|
+
this.forgetStream(parsed.data.terminal_id);
|
|
9174
|
+
stream?.splitter.dispose();
|
|
9175
|
+
logger.info("Terminal closed by the dashboard", { terminal_id: parsed.data.terminal_id, pid: session.pid });
|
|
9176
|
+
try {
|
|
9177
|
+
session.kill();
|
|
9178
|
+
} catch {
|
|
9179
|
+
}
|
|
9180
|
+
}
|
|
9181
|
+
/**
|
|
9182
|
+
* The tabs watching this shell changed size. Clamped like `open`, and a gone shell is ignored.
|
|
9183
|
+
* The pty is resized only on an actual change: every call is a `TIOCSWINSZ` and a SIGWINCH, and
|
|
9184
|
+
* a full-screen program redraws on each one.
|
|
9185
|
+
*/
|
|
9186
|
+
resize(envelope) {
|
|
9187
|
+
const parsed = TerminalResizeSchema.safeParse(envelope.payload);
|
|
9188
|
+
if (!parsed.success) return;
|
|
9189
|
+
const session = this.dependencies.registry.get(parsed.data.terminal_id);
|
|
9190
|
+
if (!session) return;
|
|
9191
|
+
this.markActive(parsed.data.terminal_id);
|
|
9192
|
+
const cols = clampGrid(parsed.data.cols, session.cols);
|
|
9193
|
+
const rows = clampGrid(parsed.data.rows, session.rows);
|
|
9194
|
+
if (cols === session.cols && rows === session.rows) return;
|
|
9195
|
+
try {
|
|
9196
|
+
session.resize(cols, rows);
|
|
9197
|
+
this.streams.get(parsed.data.terminal_id)?.mirror?.resize(cols, rows);
|
|
9198
|
+
} catch (err) {
|
|
9199
|
+
logger.debug("Terminal resize failed", { terminal_id: parsed.data.terminal_id, error: errorName2(err) });
|
|
9200
|
+
}
|
|
9201
|
+
}
|
|
9202
|
+
/** Keystrokes, written as raw bytes. A gone shell is ignored. Never logged: this is what a person typed. */
|
|
9203
|
+
input(envelope) {
|
|
9204
|
+
const parsed = TerminalInputSchema.safeParse(envelope.payload);
|
|
9205
|
+
if (!parsed.success) return;
|
|
9206
|
+
const session = this.dependencies.registry.get(parsed.data.terminal_id);
|
|
9207
|
+
if (!session) return;
|
|
9208
|
+
this.markActive(parsed.data.terminal_id);
|
|
9209
|
+
try {
|
|
9210
|
+
session.write(Buffer.from(parsed.data.data, "base64"));
|
|
9211
|
+
} catch (err) {
|
|
9212
|
+
logger.debug("Terminal write failed", { terminal_id: parsed.data.terminal_id, error: errorName2(err) });
|
|
9213
|
+
}
|
|
9214
|
+
}
|
|
9215
|
+
/** 1 allowlisted signal to the foreground process group. Anything else fails to parse and is dropped. */
|
|
9216
|
+
signal(envelope) {
|
|
9217
|
+
const parsed = TerminalSignalSchema.safeParse(envelope.payload);
|
|
9218
|
+
if (!parsed.success) {
|
|
9219
|
+
logger.debug("Ignoring a terminal signal outside the allowlist");
|
|
9220
|
+
return;
|
|
9221
|
+
}
|
|
9222
|
+
const session = this.dependencies.registry.get(parsed.data.terminal_id);
|
|
9223
|
+
if (!session) return;
|
|
9224
|
+
this.markActive(parsed.data.terminal_id);
|
|
9225
|
+
session.signalForeground(parsed.data.signal);
|
|
9226
|
+
}
|
|
9227
|
+
markActive(terminalId) {
|
|
9228
|
+
const stream = this.streams.get(terminalId);
|
|
9229
|
+
if (stream) stream.lastActivityMillis = this.nowMillis();
|
|
9230
|
+
}
|
|
9231
|
+
/**
|
|
9232
|
+
* Removed from the registry before the kill, so its exit callback finds nothing and sends no
|
|
9233
|
+
* `user_closed`. The output already read goes out first, then `close` with the reason.
|
|
9234
|
+
*/
|
|
9235
|
+
expire(terminalId, reason) {
|
|
9236
|
+
const session = this.dependencies.registry.remove(terminalId);
|
|
9237
|
+
const stream = this.streams.get(terminalId);
|
|
9238
|
+
this.forgetStream(terminalId);
|
|
9239
|
+
stream?.splitter.flush();
|
|
9240
|
+
stream?.splitter.dispose();
|
|
9241
|
+
if (!session) return;
|
|
9242
|
+
logger.info("Terminal expired", { terminal_id: terminalId, pid: session.pid, reason });
|
|
9243
|
+
try {
|
|
9244
|
+
session.kill();
|
|
9245
|
+
} catch {
|
|
9246
|
+
}
|
|
9247
|
+
this.dependencies.send(event(CHANNEL_TERMINAL, OP_CLOSE, { terminal_id: terminalId, reason }));
|
|
9248
|
+
}
|
|
9249
|
+
/**
|
|
9250
|
+
* A tab attached and asked for the screen it missed (slice T6). Answered in parts, each 1 frame,
|
|
9251
|
+
* which the dashboard routes to that tab alone.
|
|
9252
|
+
*
|
|
9253
|
+
* `after_seq` and `after_bytes` are read in the same tick as the request, before the mirror is
|
|
9254
|
+
* asked, so they name exactly the bundles the screen holds. The parts go out even with the window
|
|
9255
|
+
* closed: they are not pty output, so they take no seq and the window never counts them. A request
|
|
9256
|
+
* for a shell that is gone, or a screen too big to send, is not answered; the tab falls back.
|
|
9257
|
+
*/
|
|
9258
|
+
async snapshot(envelope) {
|
|
9259
|
+
const parsed = TerminalSnapshotRequestSchema.safeParse(envelope.payload);
|
|
9260
|
+
if (!parsed.success || this.bundleBytes === void 0) return;
|
|
9261
|
+
const { terminal_id: terminalId, snapshot_id: snapshotId } = parsed.data;
|
|
9262
|
+
const stream = this.streams.get(terminalId);
|
|
9263
|
+
if (!stream?.mirror) return;
|
|
9264
|
+
const afterSeq = stream.sentSeq;
|
|
9265
|
+
const afterBytes = stream.window.sent;
|
|
9266
|
+
const screen = await stream.mirror.snapshot();
|
|
9267
|
+
if (!screen || this.suspended || this.streams.get(terminalId) !== stream) return;
|
|
9268
|
+
const parts = splitIntoParts(Buffer.from(screen.text, "utf-8"), this.bundleBytes);
|
|
9269
|
+
parts.forEach((part, index) => {
|
|
9270
|
+
const payload = {
|
|
9271
|
+
terminal_id: terminalId,
|
|
9272
|
+
snapshot_id: snapshotId,
|
|
9273
|
+
part: index,
|
|
9274
|
+
last: index === parts.length - 1,
|
|
9275
|
+
after_seq: afterSeq,
|
|
9276
|
+
after_bytes: afterBytes,
|
|
9277
|
+
cols: screen.cols,
|
|
9278
|
+
rows: screen.rows,
|
|
9279
|
+
data: part.toString("base64")
|
|
9280
|
+
};
|
|
9281
|
+
this.dependencies.send(event(CHANNEL_TERMINAL, OP_SNAPSHOT, payload));
|
|
9282
|
+
});
|
|
9283
|
+
logger.debug("Terminal screen sent", { terminal_id: terminalId, parts: parts.length, after_seq: afterSeq });
|
|
9284
|
+
}
|
|
9285
|
+
/** A shell is gone. The context poll stops with the last one, so an idle agent spawns nothing. */
|
|
9286
|
+
forgetStream(terminalId) {
|
|
9287
|
+
this.streams.get(terminalId)?.mirror?.dispose();
|
|
9288
|
+
this.streams.delete(terminalId);
|
|
9289
|
+
this.contextTracker.forget(terminalId);
|
|
9290
|
+
if (this.streams.size === 0) this.stopContextTimer();
|
|
9291
|
+
}
|
|
9292
|
+
startContextTimer() {
|
|
9293
|
+
if (this.contextTimer || !this.dependencies.readShellContexts) return;
|
|
9294
|
+
this.contextTimer = setInterval(() => void this.pollShellContexts(), SHELL_CONTEXT_POLL_MILLIS);
|
|
9295
|
+
this.contextTimer.unref();
|
|
9296
|
+
}
|
|
9297
|
+
stopContextTimer() {
|
|
9298
|
+
if (this.contextTimer) {
|
|
9299
|
+
clearInterval(this.contextTimer);
|
|
9300
|
+
this.contextTimer = null;
|
|
9301
|
+
}
|
|
9302
|
+
}
|
|
9303
|
+
startExpiryTimer() {
|
|
9304
|
+
if (this.expiryTimer || this.idleTimeoutMillis === void 0 && this.maxSessionMillis === void 0) {
|
|
9305
|
+
return;
|
|
9306
|
+
}
|
|
9307
|
+
this.expiryTimer = setInterval(() => this.expireShells(), EXPIRY_CHECK_MILLIS);
|
|
9308
|
+
this.expiryTimer.unref();
|
|
9309
|
+
}
|
|
9310
|
+
stopExpiryTimer() {
|
|
9311
|
+
if (this.expiryTimer) {
|
|
9312
|
+
clearInterval(this.expiryTimer);
|
|
9313
|
+
this.expiryTimer = null;
|
|
9314
|
+
}
|
|
9315
|
+
}
|
|
9316
|
+
nowMillis() {
|
|
9317
|
+
return this.dependencies.now ? this.dependencies.now() : Date.now();
|
|
9318
|
+
}
|
|
9319
|
+
refuse(envelope, terminalId, code, message) {
|
|
9320
|
+
logger.info("Terminal open refused", { terminal_id: terminalId, code });
|
|
9321
|
+
const refused = { terminal_id: terminalId, error: { code, message } };
|
|
9322
|
+
this.dependencies.send(response(envelope, OP_OPENED, refused));
|
|
9323
|
+
}
|
|
9324
|
+
};
|
|
9325
|
+
}
|
|
9326
|
+
});
|
|
9327
|
+
|
|
9328
|
+
// src/devices/terminal/terminalRegistry.ts
|
|
9329
|
+
var DEFAULT_MAX_TERMINALS, TerminalRegistry;
|
|
9330
|
+
var init_terminalRegistry = __esm({
|
|
9331
|
+
"src/devices/terminal/terminalRegistry.ts"() {
|
|
9332
|
+
"use strict";
|
|
9333
|
+
init_cjs_shims();
|
|
9334
|
+
DEFAULT_MAX_TERMINALS = 3;
|
|
9335
|
+
TerminalRegistry = class {
|
|
9336
|
+
constructor() {
|
|
9337
|
+
this.handlesById = /* @__PURE__ */ new Map();
|
|
9338
|
+
this.maxTerminals = DEFAULT_MAX_TERMINALS;
|
|
9339
|
+
}
|
|
9340
|
+
setMaxTerminals(maxTerminals) {
|
|
9341
|
+
if (Number.isInteger(maxTerminals) && maxTerminals > 0) {
|
|
9342
|
+
this.maxTerminals = maxTerminals;
|
|
9343
|
+
}
|
|
9344
|
+
}
|
|
9345
|
+
has(terminalId) {
|
|
9346
|
+
return this.handlesById.has(terminalId);
|
|
9347
|
+
}
|
|
9348
|
+
isFull() {
|
|
9349
|
+
return this.handlesById.size >= this.maxTerminals;
|
|
9350
|
+
}
|
|
9351
|
+
/** False when the id is already taken or the ceiling is reached. The caller then kills the handle. */
|
|
9352
|
+
add(terminalId, handle) {
|
|
9353
|
+
if (this.handlesById.has(terminalId) || this.isFull()) return false;
|
|
9354
|
+
this.handlesById.set(terminalId, handle);
|
|
9355
|
+
return true;
|
|
9356
|
+
}
|
|
9357
|
+
get(terminalId) {
|
|
9358
|
+
return this.handlesById.get(terminalId);
|
|
9359
|
+
}
|
|
9360
|
+
/** Removes and returns the handle. Undefined the second time, so a close runs at most once. */
|
|
9361
|
+
remove(terminalId) {
|
|
9362
|
+
const handle = this.handlesById.get(terminalId);
|
|
9363
|
+
this.handlesById.delete(terminalId);
|
|
9364
|
+
return handle;
|
|
9365
|
+
}
|
|
9366
|
+
/** Every held shell with its id, in the order it was added. */
|
|
9367
|
+
entries() {
|
|
9368
|
+
return [...this.handlesById.entries()];
|
|
9369
|
+
}
|
|
9370
|
+
size() {
|
|
9371
|
+
return this.handlesById.size;
|
|
9372
|
+
}
|
|
9373
|
+
/** Removes every handle before killing any, so no exit callback finds itself still registered. */
|
|
9374
|
+
killAll() {
|
|
9375
|
+
const handles = [...this.handlesById.values()];
|
|
9376
|
+
this.handlesById.clear();
|
|
9377
|
+
for (const handle of handles) {
|
|
9378
|
+
try {
|
|
9379
|
+
handle.kill();
|
|
9380
|
+
} catch {
|
|
9381
|
+
}
|
|
9382
|
+
}
|
|
9383
|
+
}
|
|
9384
|
+
};
|
|
9385
|
+
}
|
|
9386
|
+
});
|
|
9387
|
+
|
|
9388
|
+
// src/devices/terminal/nodePtyRuntime.ts
|
|
9389
|
+
function isPackagedBinary() {
|
|
9390
|
+
return Boolean(process.pkg);
|
|
9391
|
+
}
|
|
9392
|
+
function isInsideAsarArchive(packageRoot) {
|
|
9393
|
+
if (!process.versions.electron) return false;
|
|
9394
|
+
return packageRoot.split(import_path8.default.sep).some((segment) => segment.endsWith(ASAR_ARCHIVE_SUFFIX));
|
|
9395
|
+
}
|
|
9396
|
+
function resolveNodePtyRoot() {
|
|
9397
|
+
const resolvedRoot = import_path8.default.dirname(require3.resolve(`${PACKAGE_NAME}/package.json`));
|
|
9398
|
+
if (!isPackagedBinary() && !isInsideAsarArchive(resolvedRoot)) return resolvedRoot;
|
|
9399
|
+
return unpackNodePty(resolvedRoot);
|
|
9400
|
+
}
|
|
9401
|
+
function requireNodePty(packageRoot) {
|
|
9402
|
+
return require3(packageRoot);
|
|
9403
|
+
}
|
|
9404
|
+
function spawnHelperPaths(packageRoot) {
|
|
9405
|
+
if (process.platform === "win32") return [];
|
|
9406
|
+
return NATIVE_DIRECTORIES.map((directory) => import_path8.default.join(packageRoot, directory, SPAWN_HELPER_FILE));
|
|
9407
|
+
}
|
|
9408
|
+
function hasExecutableSpawnHelper(packageRoot) {
|
|
9409
|
+
const helpers = spawnHelperPaths(packageRoot);
|
|
9410
|
+
if (helpers.length === 0) return true;
|
|
9411
|
+
return helpers.some((helper) => {
|
|
9412
|
+
try {
|
|
9413
|
+
const stat = import_fs10.default.statSync(helper);
|
|
9414
|
+
return stat.isFile() && (stat.mode & EXECUTE_BITS) !== 0;
|
|
9415
|
+
} catch {
|
|
9416
|
+
return false;
|
|
9417
|
+
}
|
|
9418
|
+
});
|
|
9419
|
+
}
|
|
9420
|
+
function makeSpawnHelperExecutable(packageRoot) {
|
|
9421
|
+
for (const helper of spawnHelperPaths(packageRoot)) {
|
|
9422
|
+
try {
|
|
9423
|
+
const stat = import_fs10.default.statSync(helper);
|
|
9424
|
+
if ((stat.mode & EXECUTE_BITS) === 0) {
|
|
9425
|
+
import_fs10.default.chmodSync(helper, EXECUTABLE_MODE);
|
|
9426
|
+
}
|
|
9427
|
+
} catch {
|
|
9428
|
+
}
|
|
9429
|
+
}
|
|
9430
|
+
}
|
|
9431
|
+
function unpackNodePty(snapshotRoot) {
|
|
9432
|
+
const unpackedRoot = unpackedRootPath(snapshotRoot);
|
|
9433
|
+
if (isLoadable(unpackedRoot)) return unpackedRoot;
|
|
9434
|
+
const stagingRoot = `${unpackedRoot}.${process.pid}`;
|
|
9435
|
+
import_fs10.default.rmSync(stagingRoot, { recursive: true, force: true });
|
|
9436
|
+
try {
|
|
9437
|
+
copyRuntimeFiles(snapshotRoot, stagingRoot);
|
|
9438
|
+
makeSpawnHelperExecutable(stagingRoot);
|
|
9439
|
+
import_fs10.default.renameSync(stagingRoot, unpackedRoot);
|
|
9440
|
+
} catch (error) {
|
|
9441
|
+
import_fs10.default.rmSync(stagingRoot, { recursive: true, force: true });
|
|
9442
|
+
if (!isLoadable(unpackedRoot)) throw error;
|
|
9443
|
+
}
|
|
9444
|
+
return unpackedRoot;
|
|
9445
|
+
}
|
|
9446
|
+
function unpackedRootPath(snapshotRoot) {
|
|
9447
|
+
const version = readPackageVersion(snapshotRoot);
|
|
9448
|
+
const directoryName = `${PACKAGE_NAME}-${version}-${process.platform}-${process.arch}`;
|
|
9449
|
+
return import_path8.default.join(getPinggyConfigDir(), RUNTIME_DIR_NAME, directoryName);
|
|
9450
|
+
}
|
|
9451
|
+
function readPackageVersion(packageRoot) {
|
|
9452
|
+
const manifest = JSON.parse(import_fs10.default.readFileSync(import_path8.default.join(packageRoot, "package.json"), "utf8"));
|
|
9453
|
+
return String(manifest.version);
|
|
9454
|
+
}
|
|
9455
|
+
function isLoadable(packageRoot) {
|
|
9456
|
+
if (!import_fs10.default.existsSync(import_path8.default.join(packageRoot, "package.json"))) return false;
|
|
9457
|
+
return NATIVE_DIRECTORIES.some((directory) => import_fs10.default.existsSync(import_path8.default.join(packageRoot, directory, NATIVE_FILE)));
|
|
9458
|
+
}
|
|
9459
|
+
function copyRuntimeFiles(sourceRoot, targetRoot) {
|
|
9460
|
+
import_fs10.default.mkdirSync(targetRoot, { recursive: true });
|
|
9461
|
+
copyFile(import_path8.default.join(sourceRoot, "package.json"), import_path8.default.join(targetRoot, "package.json"));
|
|
9462
|
+
copyDirectory(import_path8.default.join(sourceRoot, "lib"), import_path8.default.join(targetRoot, "lib"));
|
|
9463
|
+
for (const directory of NATIVE_DIRECTORIES) {
|
|
9464
|
+
const source = import_path8.default.join(sourceRoot, directory);
|
|
9465
|
+
if (import_fs10.default.existsSync(source)) {
|
|
9466
|
+
copyDirectory(source, import_path8.default.join(targetRoot, directory));
|
|
9467
|
+
}
|
|
9468
|
+
}
|
|
9469
|
+
}
|
|
9470
|
+
function copyDirectory(sourceDirectory, targetDirectory) {
|
|
9471
|
+
import_fs10.default.mkdirSync(targetDirectory, { recursive: true });
|
|
9472
|
+
for (const entry of import_fs10.default.readdirSync(sourceDirectory, { withFileTypes: true })) {
|
|
9473
|
+
const source = import_path8.default.join(sourceDirectory, entry.name);
|
|
9474
|
+
const target = import_path8.default.join(targetDirectory, entry.name);
|
|
9475
|
+
if (entry.isDirectory()) {
|
|
9476
|
+
copyDirectory(source, target);
|
|
9477
|
+
continue;
|
|
9478
|
+
}
|
|
9479
|
+
copyFile(source, target);
|
|
9480
|
+
}
|
|
9481
|
+
}
|
|
9482
|
+
function copyFile(source, target) {
|
|
9483
|
+
import_fs10.default.writeFileSync(target, import_fs10.default.readFileSync(source));
|
|
9484
|
+
}
|
|
9485
|
+
var import_fs10, import_path8, import_module2, require3, PACKAGE_NAME, NATIVE_FILE, SPAWN_HELPER_FILE, EXECUTABLE_MODE, EXECUTE_BITS, RUNTIME_DIR_NAME, ASAR_ARCHIVE_SUFFIX, NATIVE_DIRECTORIES;
|
|
9486
|
+
var init_nodePtyRuntime = __esm({
|
|
9487
|
+
"src/devices/terminal/nodePtyRuntime.ts"() {
|
|
9488
|
+
"use strict";
|
|
9489
|
+
init_cjs_shims();
|
|
9490
|
+
import_fs10 = __toESM(require("fs"), 1);
|
|
9491
|
+
import_path8 = __toESM(require("path"), 1);
|
|
9492
|
+
import_module2 = require("module");
|
|
9493
|
+
init_configDir();
|
|
9494
|
+
require3 = (0, import_module2.createRequire)(importMetaUrl);
|
|
9495
|
+
PACKAGE_NAME = "node-pty";
|
|
9496
|
+
NATIVE_FILE = "pty.node";
|
|
9497
|
+
SPAWN_HELPER_FILE = "spawn-helper";
|
|
9498
|
+
EXECUTABLE_MODE = 493;
|
|
9499
|
+
EXECUTE_BITS = 73;
|
|
9500
|
+
RUNTIME_DIR_NAME = "runtime";
|
|
9501
|
+
ASAR_ARCHIVE_SUFFIX = ".asar";
|
|
9502
|
+
NATIVE_DIRECTORIES = [
|
|
9503
|
+
import_path8.default.join("build", "Release"),
|
|
9504
|
+
import_path8.default.join("prebuilds", `${process.platform}-${process.arch}`)
|
|
9505
|
+
];
|
|
9506
|
+
}
|
|
9507
|
+
});
|
|
9508
|
+
|
|
9509
|
+
// src/devices/terminal/ptySession.ts
|
|
9510
|
+
function foregroundProcessGroup(shellPid) {
|
|
9511
|
+
return new Promise((resolve) => {
|
|
9512
|
+
(0, import_child_process3.execFile)("ps", ["-o", "tpgid=", "-p", String(shellPid)], (err, stdout) => {
|
|
9513
|
+
const processGroupId = err ? NaN : Number.parseInt(String(stdout).trim(), 10);
|
|
9514
|
+
resolve(Number.isInteger(processGroupId) && processGroupId > 0 ? processGroupId : null);
|
|
9515
|
+
});
|
|
9516
|
+
});
|
|
9517
|
+
}
|
|
9518
|
+
async function signalForegroundGroup(shellPid, signal) {
|
|
9519
|
+
if (process.platform === "win32") {
|
|
9520
|
+
logger.debug("Terminal signal ignored on Windows", { pid: shellPid, signal });
|
|
9521
|
+
return;
|
|
9522
|
+
}
|
|
9523
|
+
const processGroupId = await foregroundProcessGroup(shellPid) ?? shellPid;
|
|
9524
|
+
try {
|
|
9525
|
+
process.kill(-processGroupId, `SIG${signal}`);
|
|
9526
|
+
} catch (err) {
|
|
9527
|
+
logger.debug("Terminal signal not delivered", {
|
|
9528
|
+
pid: shellPid,
|
|
9529
|
+
signal,
|
|
9530
|
+
error: err instanceof Error ? err.name : typeof err
|
|
9531
|
+
});
|
|
9532
|
+
}
|
|
9533
|
+
}
|
|
9534
|
+
function loadNodePty() {
|
|
9535
|
+
if (loadedPty !== void 0) return loadedPty;
|
|
9536
|
+
try {
|
|
9537
|
+
const packageRoot = resolveNodePtyRoot();
|
|
9538
|
+
const pty = requireNodePty(packageRoot);
|
|
9539
|
+
ensureSpawnHelperExecutable(packageRoot);
|
|
9540
|
+
loadedPty = hasExecutableSpawnHelper(packageRoot) ? pty : null;
|
|
9541
|
+
} catch {
|
|
9542
|
+
loadedPty = null;
|
|
9543
|
+
}
|
|
9544
|
+
return loadedPty;
|
|
9545
|
+
}
|
|
9546
|
+
function isTerminalSupported() {
|
|
9547
|
+
return loadNodePty() !== null;
|
|
9548
|
+
}
|
|
9549
|
+
function ensureSpawnHelperExecutable(packageRoot) {
|
|
9550
|
+
makeSpawnHelperExecutable(packageRoot ?? resolveNodePtyRoot());
|
|
9551
|
+
}
|
|
9552
|
+
function resolveCwd(requested) {
|
|
9553
|
+
if (requested) {
|
|
9554
|
+
try {
|
|
9555
|
+
if (import_fs11.default.statSync(requested).isDirectory()) return requested;
|
|
9556
|
+
} catch {
|
|
9557
|
+
}
|
|
9558
|
+
}
|
|
9559
|
+
return import_os7.default.homedir();
|
|
9560
|
+
}
|
|
9561
|
+
function spawnPty(request2) {
|
|
9562
|
+
const pty = loadNodePty();
|
|
9563
|
+
if (!pty) {
|
|
9564
|
+
throw new Error("node-pty is not available on this machine");
|
|
9565
|
+
}
|
|
9566
|
+
const process_ = pty.spawn(request2.shell, [], {
|
|
9567
|
+
name: TERM_NAME,
|
|
9568
|
+
cols: request2.cols,
|
|
9569
|
+
rows: request2.rows,
|
|
9570
|
+
cwd: resolveCwd(request2.cwd),
|
|
9571
|
+
env: { ...process.env, TERM: TERM_NAME },
|
|
9572
|
+
// Buffers rather than strings. node-pty decodes per read otherwise, and a read that ends
|
|
9573
|
+
// mid-character yields a replacement character the browser can never recover.
|
|
9574
|
+
encoding: null
|
|
9575
|
+
});
|
|
9576
|
+
return {
|
|
9577
|
+
pid: process_.pid,
|
|
9578
|
+
shell: request2.shell,
|
|
9579
|
+
get cols() {
|
|
9580
|
+
return process_.cols;
|
|
9581
|
+
},
|
|
9582
|
+
get rows() {
|
|
9583
|
+
return process_.rows;
|
|
9584
|
+
},
|
|
9585
|
+
kill: () => process_.kill(),
|
|
9586
|
+
onExit: (listener) => {
|
|
9587
|
+
process_.onExit(({ exitCode, signal }) => listener(exitCode, signal));
|
|
9588
|
+
},
|
|
9589
|
+
onData: (listener) => {
|
|
9590
|
+
process_.onData((chunk) => {
|
|
9591
|
+
listener(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk);
|
|
9592
|
+
});
|
|
9593
|
+
},
|
|
9594
|
+
pause: () => process_.pause(),
|
|
9595
|
+
resume: () => process_.resume(),
|
|
9596
|
+
resize: (cols, rows) => process_.resize(cols, rows),
|
|
9597
|
+
write: (bytes) => process_.write(bytes),
|
|
9598
|
+
signalForeground: (signal) => {
|
|
9599
|
+
void signalForegroundGroup(process_.pid, signal);
|
|
9600
|
+
}
|
|
9601
|
+
};
|
|
9602
|
+
}
|
|
9603
|
+
var import_child_process3, import_fs11, import_os7, loadedPty, TERM_NAME;
|
|
9604
|
+
var init_ptySession = __esm({
|
|
9605
|
+
"src/devices/terminal/ptySession.ts"() {
|
|
9606
|
+
"use strict";
|
|
9607
|
+
init_cjs_shims();
|
|
9608
|
+
import_child_process3 = require("child_process");
|
|
9609
|
+
import_fs11 = __toESM(require("fs"), 1);
|
|
9610
|
+
import_os7 = __toESM(require("os"), 1);
|
|
9611
|
+
init_logger();
|
|
9612
|
+
init_nodePtyRuntime();
|
|
9613
|
+
TERM_NAME = "xterm-256color";
|
|
9614
|
+
}
|
|
9615
|
+
});
|
|
9616
|
+
|
|
9617
|
+
// src/devices/terminal/shellAllowlist.ts
|
|
9618
|
+
function readShellEnvironment() {
|
|
9619
|
+
let etcShells = null;
|
|
9620
|
+
try {
|
|
9621
|
+
etcShells = import_fs12.default.readFileSync(ETC_SHELLS_PATH, "utf8");
|
|
9622
|
+
} catch {
|
|
9623
|
+
etcShells = null;
|
|
9624
|
+
}
|
|
9625
|
+
return {
|
|
9626
|
+
platform: process.platform,
|
|
9627
|
+
loginShell: process.env.SHELL,
|
|
9628
|
+
etcShells,
|
|
9629
|
+
exists: (path12) => {
|
|
9630
|
+
try {
|
|
9631
|
+
return import_fs12.default.statSync(path12).isFile();
|
|
9632
|
+
} catch {
|
|
9633
|
+
return false;
|
|
9634
|
+
}
|
|
9635
|
+
}
|
|
9636
|
+
};
|
|
9637
|
+
}
|
|
9638
|
+
function parseEtcShells(contents) {
|
|
9639
|
+
return contents.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("/"));
|
|
9640
|
+
}
|
|
9641
|
+
function allowedShells(environment) {
|
|
9642
|
+
if (environment.platform === "win32") return WINDOWS_SHELLS;
|
|
9643
|
+
const listed = environment.etcShells === null ? [] : parseEtcShells(environment.etcShells);
|
|
9644
|
+
return listed.length > 0 ? listed : FALLBACK_UNIX_SHELLS;
|
|
9645
|
+
}
|
|
9646
|
+
function resolveShell(requested, environment) {
|
|
9647
|
+
const allowed = allowedShells(environment);
|
|
9648
|
+
const usable = (shell) => environment.platform === "win32" || environment.exists(shell);
|
|
9649
|
+
if (requested !== null && requested !== void 0) {
|
|
9650
|
+
if (allowed.includes(requested) && usable(requested)) {
|
|
9651
|
+
return { shell: requested };
|
|
9652
|
+
}
|
|
9653
|
+
return { error: "That shell is not allowed on this machine." };
|
|
9654
|
+
}
|
|
9655
|
+
const loginShell = environment.loginShell;
|
|
9656
|
+
if (loginShell && allowed.includes(loginShell) && usable(loginShell)) {
|
|
9657
|
+
return { shell: loginShell };
|
|
9658
|
+
}
|
|
9659
|
+
const firstUsable = allowed.find(usable);
|
|
9660
|
+
return firstUsable ? { shell: firstUsable } : { error: "No allowed shell exists on this machine." };
|
|
9661
|
+
}
|
|
9662
|
+
var import_fs12, FALLBACK_UNIX_SHELLS, WINDOWS_SHELLS, ETC_SHELLS_PATH;
|
|
9663
|
+
var init_shellAllowlist = __esm({
|
|
9664
|
+
"src/devices/terminal/shellAllowlist.ts"() {
|
|
9665
|
+
"use strict";
|
|
9666
|
+
init_cjs_shims();
|
|
9667
|
+
import_fs12 = __toESM(require("fs"), 1);
|
|
9668
|
+
FALLBACK_UNIX_SHELLS = ["/bin/bash", "/bin/zsh", "/bin/sh", "/usr/bin/bash", "/usr/bin/zsh"];
|
|
9669
|
+
WINDOWS_SHELLS = ["powershell.exe", "cmd.exe"];
|
|
9670
|
+
ETC_SHELLS_PATH = "/etc/shells";
|
|
9671
|
+
}
|
|
9672
|
+
});
|
|
9673
|
+
|
|
9674
|
+
// src/devices/deviceAgent.ts
|
|
9675
|
+
function buildCapabilities(terminalSupported, screenRestoreSupported = false) {
|
|
9676
|
+
if (!terminalSupported) return BASE_CAPABILITIES;
|
|
9677
|
+
return screenRestoreSupported ? [...BASE_CAPABILITIES, CAPABILITY_TERMINAL, CAPABILITY_TERMINAL_SNAPSHOT] : [...BASE_CAPABILITIES, CAPABILITY_TERMINAL];
|
|
9678
|
+
}
|
|
9679
|
+
function buildDeviceAgentWsUrl(manage) {
|
|
9680
|
+
let baseUrl = (manage || DEFAULT_SERVER).trim();
|
|
9681
|
+
if (!(baseUrl.startsWith("ws://") || baseUrl.startsWith("wss://"))) {
|
|
9682
|
+
baseUrl = "wss://" + baseUrl;
|
|
9683
|
+
}
|
|
9684
|
+
return `${baseUrl.replace(/\/$/, "")}/backend/api/v1/device-agent/ws/connect`;
|
|
9685
|
+
}
|
|
9686
|
+
function sleep2(millis, state) {
|
|
9687
|
+
return new Promise((resolve) => {
|
|
9688
|
+
const timer = setTimeout(() => {
|
|
9689
|
+
state.cancelPendingSleep = null;
|
|
9690
|
+
resolve();
|
|
9691
|
+
}, millis);
|
|
9692
|
+
state.cancelPendingSleep = () => {
|
|
9693
|
+
clearTimeout(timer);
|
|
9694
|
+
state.cancelPendingSleep = null;
|
|
9695
|
+
resolve();
|
|
9696
|
+
};
|
|
9697
|
+
});
|
|
9698
|
+
}
|
|
9699
|
+
function buildHello(terminalHandler) {
|
|
9700
|
+
return {
|
|
9701
|
+
agent_version: getVersion(),
|
|
9702
|
+
os: import_os8.default.platform(),
|
|
9703
|
+
hostname: import_os8.default.hostname(),
|
|
9704
|
+
capabilities: buildCapabilities(isTerminalSupported(), isScreenMirrorSupported()),
|
|
9705
|
+
terminals: terminalHandler.held()
|
|
9706
|
+
};
|
|
9707
|
+
}
|
|
9708
|
+
function startMetricsReporting(intervalSeconds, collect, send) {
|
|
9709
|
+
let stopped = false;
|
|
9710
|
+
const report = () => {
|
|
9711
|
+
collect().then((metrics) => {
|
|
9712
|
+
if (!stopped) send(metrics);
|
|
9713
|
+
}).catch((err) => logger.warn("Device metrics collection failed", { error: String(err) }));
|
|
9714
|
+
};
|
|
9715
|
+
report();
|
|
9716
|
+
const timer = setInterval(report, intervalSeconds * 1e3);
|
|
9717
|
+
return () => {
|
|
9718
|
+
stopped = true;
|
|
9719
|
+
clearInterval(timer);
|
|
9720
|
+
};
|
|
9721
|
+
}
|
|
9722
|
+
function identityForRun(token, server, stored) {
|
|
9723
|
+
if (stored?.token === token) {
|
|
9724
|
+
return { ...stored, server };
|
|
9725
|
+
}
|
|
9726
|
+
return { device_agent_id: null, token, server, enrolled_at: null };
|
|
9727
|
+
}
|
|
9728
|
+
function startDeviceAgent(token, manage, options = {}) {
|
|
9729
|
+
const server = manage || DEFAULT_SERVER;
|
|
9730
|
+
const reporter = options.reporter ?? printingReporter;
|
|
9731
|
+
const stored = readDeviceIdentity();
|
|
9732
|
+
if (stored?.device_agent_id && stored.token !== token) {
|
|
9733
|
+
reporter.replacingDevice(stored.device_agent_id);
|
|
9734
|
+
}
|
|
9735
|
+
const connection = { send: null, close: null };
|
|
9736
|
+
const context = {
|
|
9737
|
+
wsUrl: buildDeviceAgentWsUrl(manage),
|
|
9738
|
+
server,
|
|
9739
|
+
identity: identityForRun(token, server, stored),
|
|
9740
|
+
reconnectPolicy: options.reconnectPolicy ?? new ReconnectPolicy(),
|
|
9741
|
+
handshakeTimeoutMillis: options.handshakeTimeoutMillis ?? HANDSHAKE_TIMEOUT_MILLIS,
|
|
9742
|
+
terminalHandler: new TerminalHandler({
|
|
9743
|
+
send: (frame) => connection.send?.(frame),
|
|
9744
|
+
spawn: spawnPty,
|
|
9745
|
+
resolveShell: (requested) => resolveShell(requested, readShellEnvironment()),
|
|
9746
|
+
registry: new TerminalRegistry(),
|
|
9747
|
+
readShellContexts: processTableReader(),
|
|
9748
|
+
resolvedHome: resolvedHomeDirectory()
|
|
9749
|
+
}),
|
|
9750
|
+
connection,
|
|
9751
|
+
reporter,
|
|
9752
|
+
writeIdentity: options.writeIdentity ?? writeDeviceIdentity,
|
|
9753
|
+
options,
|
|
9754
|
+
state: { stopRequested: false, cancelPendingSleep: null, error: null, welcomed: false }
|
|
9755
|
+
};
|
|
9756
|
+
const done = runLoop(context);
|
|
9757
|
+
return {
|
|
9758
|
+
done,
|
|
9759
|
+
stop: () => {
|
|
9760
|
+
context.state.stopRequested = true;
|
|
9761
|
+
context.state.cancelPendingSleep?.();
|
|
9762
|
+
connection.close?.();
|
|
9763
|
+
return done;
|
|
9764
|
+
}
|
|
9765
|
+
};
|
|
9766
|
+
}
|
|
9767
|
+
async function runDeviceAgent(token, manage, options = {}) {
|
|
9768
|
+
const run2 = startDeviceAgent(token, manage, options);
|
|
9769
|
+
const sigintHandler = () => {
|
|
9770
|
+
void run2.stop();
|
|
9771
|
+
};
|
|
9772
|
+
process.once("SIGINT", sigintHandler);
|
|
9773
|
+
try {
|
|
9774
|
+
await run2.done;
|
|
9775
|
+
} finally {
|
|
9776
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
9777
|
+
}
|
|
9778
|
+
}
|
|
9779
|
+
async function runLoop(context) {
|
|
9780
|
+
const { state, reporter, reconnectPolicy } = context;
|
|
9781
|
+
while (!state.stopRequested) {
|
|
9782
|
+
reporter.connecting(context.server);
|
|
9783
|
+
const outcome = await connectOnce(context);
|
|
9784
|
+
if (outcome === "terminal" || state.stopRequested) {
|
|
9785
|
+
break;
|
|
9786
|
+
}
|
|
9787
|
+
if (context.options.verifyOnly) {
|
|
9788
|
+
state.error = {
|
|
9789
|
+
code: AGENT_ERROR_NOT_CONNECTED,
|
|
9790
|
+
message: `Could not connect to ${context.server}. Check the address and the network, then try again.`
|
|
9791
|
+
};
|
|
9792
|
+
break;
|
|
9793
|
+
}
|
|
9794
|
+
const delayMillis = reconnectPolicy.nextDelayMillis();
|
|
9795
|
+
reporter.reconnecting(delayMillis, reconnectPolicy.attempt);
|
|
9796
|
+
await sleep2(delayMillis, state);
|
|
9797
|
+
}
|
|
9798
|
+
context.terminalHandler.closeAll();
|
|
9799
|
+
return { error: state.error, welcomed: state.welcomed };
|
|
9800
|
+
}
|
|
9801
|
+
function fail(context, error) {
|
|
9802
|
+
context.state.error = error;
|
|
9803
|
+
context.reporter.failed(error);
|
|
9804
|
+
return "terminal";
|
|
9805
|
+
}
|
|
9806
|
+
function connectOnce(context) {
|
|
9807
|
+
const { identity, reconnectPolicy, terminalHandler, connection, reporter, state, options } = context;
|
|
9808
|
+
return new Promise((resolve) => {
|
|
9809
|
+
const ws = new import_ws3.default(context.wsUrl, { headers: { [TOKEN_HEADER]: identity.token } });
|
|
9810
|
+
let heartbeat = null;
|
|
9811
|
+
let stopMetrics = null;
|
|
9812
|
+
let stopTunnels = null;
|
|
9813
|
+
let pingTimer = null;
|
|
9814
|
+
let pongDeadline = null;
|
|
9815
|
+
let handshakeDeadline = null;
|
|
9816
|
+
let closeDeadline = null;
|
|
9817
|
+
let settled = false;
|
|
9818
|
+
const stopTimers = () => {
|
|
9819
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
9820
|
+
heartbeat = null;
|
|
9821
|
+
stopMetrics?.();
|
|
9822
|
+
stopMetrics = null;
|
|
9823
|
+
stopTunnels?.();
|
|
9824
|
+
stopTunnels = null;
|
|
9825
|
+
if (pingTimer) clearInterval(pingTimer);
|
|
9826
|
+
pingTimer = null;
|
|
9827
|
+
if (pongDeadline) clearTimeout(pongDeadline);
|
|
9828
|
+
pongDeadline = null;
|
|
9829
|
+
if (handshakeDeadline) clearTimeout(handshakeDeadline);
|
|
9830
|
+
handshakeDeadline = null;
|
|
9831
|
+
};
|
|
9832
|
+
const finish = (outcome) => {
|
|
9833
|
+
if (settled) return;
|
|
9834
|
+
settled = true;
|
|
9835
|
+
stopTimers();
|
|
9836
|
+
if (closeDeadline) clearTimeout(closeDeadline);
|
|
9837
|
+
closeDeadline = null;
|
|
9838
|
+
if (reconnectPolicy.markDisconnected()) {
|
|
9839
|
+
logger.info("Connection held long enough to reset the reconnect schedule");
|
|
9840
|
+
}
|
|
9841
|
+
connection.send = null;
|
|
9842
|
+
connection.close = null;
|
|
9843
|
+
terminalHandler.suspend();
|
|
9844
|
+
resolve(outcome);
|
|
9845
|
+
};
|
|
9846
|
+
const sendFrame = (frame) => {
|
|
9847
|
+
if (ws.readyState === import_ws3.default.OPEN) {
|
|
9848
|
+
ws.send(JSON.stringify(frame));
|
|
9849
|
+
}
|
|
9850
|
+
};
|
|
9851
|
+
const dropDeadSocket = (message) => {
|
|
9852
|
+
logger.warn(message);
|
|
9853
|
+
reporter.warn(message);
|
|
9854
|
+
ws.terminate();
|
|
9855
|
+
};
|
|
9856
|
+
connection.close = () => {
|
|
9857
|
+
if (ws.readyState === import_ws3.default.CONNECTING) {
|
|
9858
|
+
ws.terminate();
|
|
9859
|
+
return;
|
|
9860
|
+
}
|
|
9861
|
+
ws.close(CLOSE_CODE_NORMAL);
|
|
9862
|
+
closeDeadline = setTimeout(() => ws.terminate(), CLOSE_TIMEOUT_MILLIS);
|
|
9863
|
+
};
|
|
9864
|
+
ws.once("open", () => {
|
|
9865
|
+
logger.info("Device agent socket open, sending hello");
|
|
9866
|
+
connection.send = sendFrame;
|
|
9867
|
+
sendFrame(request(CHANNEL_SYSTEM, OP_HELLO, buildHello(terminalHandler)));
|
|
9868
|
+
handshakeDeadline = setTimeout(() => {
|
|
9869
|
+
dropDeadSocket("No welcome from the dashboard. Reconnecting.");
|
|
9870
|
+
}, context.handshakeTimeoutMillis);
|
|
9871
|
+
});
|
|
9872
|
+
const startPongWatchdog = (intervalSeconds) => {
|
|
9873
|
+
const intervalMillis = intervalSeconds * 1e3;
|
|
9874
|
+
const graceMillis = intervalMillis * PONG_GRACE_INTERVALS;
|
|
9875
|
+
const armDeadline = () => {
|
|
9876
|
+
if (pongDeadline) clearTimeout(pongDeadline);
|
|
9877
|
+
pongDeadline = setTimeout(() => {
|
|
9878
|
+
dropDeadSocket("Connection stopped answering. Reconnecting.");
|
|
9879
|
+
}, graceMillis);
|
|
9880
|
+
};
|
|
9881
|
+
ws.on("pong", armDeadline);
|
|
9882
|
+
pingTimer = setInterval(() => {
|
|
9883
|
+
if (ws.readyState === import_ws3.default.OPEN) {
|
|
9884
|
+
ws.ping();
|
|
9885
|
+
}
|
|
9886
|
+
}, intervalMillis);
|
|
9887
|
+
armDeadline();
|
|
9888
|
+
};
|
|
9889
|
+
const startHeartbeat = (intervalSeconds) => {
|
|
9890
|
+
heartbeat = setInterval(
|
|
9891
|
+
() => sendFrame(
|
|
9892
|
+
event(CHANNEL_SYSTEM, OP_HEARTBEAT, { uptime_seconds: Math.floor(import_os8.default.uptime()) })
|
|
9893
|
+
),
|
|
9894
|
+
intervalSeconds * 1e3
|
|
9895
|
+
);
|
|
9896
|
+
};
|
|
9897
|
+
const onWelcome = (welcome) => {
|
|
9898
|
+
stopTimers();
|
|
9899
|
+
if (options.verifyOnly) {
|
|
9900
|
+
state.stopRequested = true;
|
|
9901
|
+
connection.close?.();
|
|
9902
|
+
return;
|
|
9903
|
+
}
|
|
9904
|
+
reconnectPolicy.markConnected();
|
|
9905
|
+
terminalHandler.configure(
|
|
9906
|
+
welcome.terminal_enabled,
|
|
9907
|
+
welcome.max_terminals_per_device,
|
|
9908
|
+
welcome.terminal_window_bytes,
|
|
9909
|
+
welcome.max_frame_bytes,
|
|
9910
|
+
welcome.terminal_idle_timeout_seconds,
|
|
9911
|
+
welcome.terminal_max_session_seconds
|
|
9912
|
+
);
|
|
9913
|
+
terminalHandler.resumeAll();
|
|
9914
|
+
startHeartbeat(welcome.heartbeat_interval_seconds);
|
|
9915
|
+
startPongWatchdog(welcome.heartbeat_interval_seconds);
|
|
9916
|
+
sendFrame(event(CHANNEL_DEVICE, OP_INFO, collectSystemInfo()));
|
|
9917
|
+
stopMetrics = startMetricsReporting(
|
|
9918
|
+
welcome.stats_interval_seconds,
|
|
9919
|
+
collectMetrics,
|
|
9920
|
+
(metrics) => sendFrame(event(CHANNEL_DEVICE, OP_METRICS, metrics))
|
|
9921
|
+
);
|
|
9922
|
+
stopTunnels = startTunnelReporting(
|
|
9923
|
+
() => collectTunnelList(options.tunnelListSource),
|
|
9924
|
+
(tunnels) => sendFrame(event(CHANNEL_DEVICE, OP_TUNNELS, tunnels))
|
|
9925
|
+
);
|
|
9926
|
+
};
|
|
9927
|
+
ws.on("message", (data) => {
|
|
9928
|
+
const envelope = parseEnvelope(data.toString("utf8"));
|
|
9929
|
+
if (!envelope) {
|
|
9930
|
+
logger.debug("Ignoring unparseable frame");
|
|
9931
|
+
return;
|
|
9932
|
+
}
|
|
9933
|
+
if (envelope.ch === CHANNEL_TERMINAL) {
|
|
9934
|
+
terminalHandler.handle(envelope);
|
|
9935
|
+
return;
|
|
9936
|
+
}
|
|
9937
|
+
if (envelope.ch === CHANNEL_TUNNEL) {
|
|
9938
|
+
void handleTunnelAction(envelope, (frame) => connection.send?.(frame), options.tunnelActions).then(() => stopTunnels?.pollNow());
|
|
9939
|
+
return;
|
|
9940
|
+
}
|
|
9941
|
+
const outcome = handleFrame(envelope, context, onWelcome);
|
|
9942
|
+
if (outcome === "terminal") {
|
|
9943
|
+
finish("terminal");
|
|
9944
|
+
ws.close();
|
|
9945
|
+
}
|
|
9946
|
+
});
|
|
9947
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
9948
|
+
if (res.statusCode === 401) {
|
|
9949
|
+
finish(fail(context, {
|
|
9950
|
+
code: AGENT_ERROR_UNAUTHORIZED,
|
|
9951
|
+
message: "Unauthorized. This device token is not valid. Re-enrol this machine with: pinggy device login"
|
|
9952
|
+
}));
|
|
9953
|
+
} else {
|
|
9954
|
+
reporter.warn(`Unexpected HTTP ${res.statusCode}.`);
|
|
9955
|
+
finish("retry");
|
|
9956
|
+
}
|
|
9957
|
+
ws.close();
|
|
9958
|
+
});
|
|
9959
|
+
ws.on("close", (code, reason) => {
|
|
9960
|
+
logger.info("Device agent socket closed", { code, reason: reason.toString() });
|
|
9961
|
+
finish(code === CLOSE_CODE_REVOKED ? "terminal" : "retry");
|
|
9962
|
+
});
|
|
9963
|
+
ws.on("error", (err) => {
|
|
9964
|
+
logger.warn("Device agent socket error", { error: err.message });
|
|
9965
|
+
if (!state.stopRequested) {
|
|
9966
|
+
reporter.warn(err.message);
|
|
9967
|
+
}
|
|
9968
|
+
finish("retry");
|
|
9969
|
+
});
|
|
9970
|
+
});
|
|
9971
|
+
}
|
|
9972
|
+
function handleFrame(envelope, context, onWelcome) {
|
|
9973
|
+
const { identity, reporter } = context;
|
|
9974
|
+
if (envelope.ch !== CHANNEL_SYSTEM) {
|
|
9975
|
+
logger.debug("Ignoring frame on unknown channel", { channel: envelope.ch });
|
|
9976
|
+
return "retry";
|
|
9977
|
+
}
|
|
9978
|
+
if (envelope.op === OP_WELCOME) {
|
|
9979
|
+
const error = ErrorPayloadSchema.safeParse(envelope.payload);
|
|
9980
|
+
if (error.success) {
|
|
9981
|
+
if (error.data.error.code === ERROR_ALREADY_CONNECTED) {
|
|
9982
|
+
reporter.warn("This device is connected elsewhere. Retrying.");
|
|
9983
|
+
return "retry";
|
|
9984
|
+
}
|
|
9985
|
+
return fail(context, {
|
|
9986
|
+
code: AGENT_ERROR_HANDSHAKE_REFUSED,
|
|
9987
|
+
message: `Handshake refused: ${error.data.error.message}`
|
|
9988
|
+
});
|
|
9989
|
+
}
|
|
9990
|
+
const welcome = WelcomeSchema.safeParse(envelope.payload);
|
|
9991
|
+
if (!welcome.success) {
|
|
9992
|
+
logger.warn("Welcome payload could not be read");
|
|
9993
|
+
return "retry";
|
|
9994
|
+
}
|
|
9995
|
+
identity.device_agent_id = welcome.data.device_agent_id;
|
|
9996
|
+
identity.enrolled_at = identity.enrolled_at ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
9997
|
+
context.writeIdentity(identity);
|
|
9998
|
+
context.state.welcomed = true;
|
|
9999
|
+
reporter.connected(welcome.data);
|
|
10000
|
+
onWelcome(welcome.data);
|
|
10001
|
+
return "retry";
|
|
10002
|
+
}
|
|
10003
|
+
if (envelope.op === OP_DISCONNECT) {
|
|
10004
|
+
const disconnect = DisconnectSchema.safeParse(envelope.payload);
|
|
10005
|
+
const reason = disconnect.success ? disconnect.data.reason : "unknown";
|
|
10006
|
+
const { outcome, message } = describeDisconnect(reason);
|
|
10007
|
+
if (outcome === "retry") {
|
|
10008
|
+
reporter.warn(message);
|
|
10009
|
+
return "retry";
|
|
10010
|
+
}
|
|
10011
|
+
return fail(context, { code: disconnectErrorCode(reason), message });
|
|
10012
|
+
}
|
|
10013
|
+
logger.debug("Ignoring unhandled system op", { op: envelope.op });
|
|
10014
|
+
return "retry";
|
|
10015
|
+
}
|
|
10016
|
+
function disconnectErrorCode(reason) {
|
|
10017
|
+
return reason in DISCONNECT_MESSAGES ? reason : AGENT_ERROR_DISCONNECTED;
|
|
10018
|
+
}
|
|
10019
|
+
function describeDisconnect(reason) {
|
|
10020
|
+
if (reason === DISCONNECT_REASON_SHUTDOWN) {
|
|
10021
|
+
return { outcome: "retry", message: "Server is shutting down. Reconnecting." };
|
|
10022
|
+
}
|
|
10023
|
+
return {
|
|
10024
|
+
outcome: "terminal",
|
|
10025
|
+
message: DISCONNECT_MESSAGES[reason] ?? `Disconnected by the dashboard: ${reason}. Re-enrol this device to continue.`
|
|
10026
|
+
};
|
|
10027
|
+
}
|
|
10028
|
+
var import_ws3, import_os8, CLOSE_CODE_REVOKED, CLOSE_CODE_NORMAL, CLOSE_TIMEOUT_MILLIS, TOKEN_HEADER, DEFAULT_SERVER, ERROR_ALREADY_CONNECTED, AGENT_ERROR_UNAUTHORIZED, AGENT_ERROR_HANDSHAKE_REFUSED, AGENT_ERROR_DISCONNECTED, AGENT_ERROR_NOT_CONNECTED, HANDSHAKE_TIMEOUT_MILLIS, PONG_GRACE_INTERVALS, BASE_CAPABILITIES, CAPABILITY_TERMINAL, CAPABILITY_TERMINAL_SNAPSHOT, DISCONNECT_REASON_SHUTDOWN, DISCONNECT_MESSAGES;
|
|
10029
|
+
var init_deviceAgent = __esm({
|
|
10030
|
+
"src/devices/deviceAgent.ts"() {
|
|
10031
|
+
"use strict";
|
|
10032
|
+
init_cjs_shims();
|
|
10033
|
+
import_ws3 = __toESM(require("ws"), 1);
|
|
10034
|
+
import_os8 = __toESM(require("os"), 1);
|
|
10035
|
+
init_logger();
|
|
10036
|
+
init_util();
|
|
10037
|
+
init_envelope();
|
|
10038
|
+
init_device_schema();
|
|
10039
|
+
init_deviceIdentity();
|
|
10040
|
+
init_deviceAgentReporter();
|
|
10041
|
+
init_systemInfo();
|
|
10042
|
+
init_metrics();
|
|
10043
|
+
init_tunnelList();
|
|
10044
|
+
init_tunnelActions();
|
|
10045
|
+
init_reconnect();
|
|
10046
|
+
init_terminal_schema();
|
|
10047
|
+
init_terminalHandler();
|
|
10048
|
+
init_screenMirror();
|
|
10049
|
+
init_terminalRegistry();
|
|
10050
|
+
init_ptySession();
|
|
10051
|
+
init_shellAllowlist();
|
|
10052
|
+
init_shellContext();
|
|
10053
|
+
init_deviceAgentReporter();
|
|
10054
|
+
CLOSE_CODE_REVOKED = 4001;
|
|
10055
|
+
CLOSE_CODE_NORMAL = 1e3;
|
|
10056
|
+
CLOSE_TIMEOUT_MILLIS = 3e3;
|
|
10057
|
+
TOKEN_HEADER = "X-Pinggy-Device-Token";
|
|
10058
|
+
DEFAULT_SERVER = "dashboard.pinggy.io";
|
|
10059
|
+
ERROR_ALREADY_CONNECTED = "already_connected";
|
|
10060
|
+
AGENT_ERROR_UNAUTHORIZED = "unauthorized";
|
|
10061
|
+
AGENT_ERROR_HANDSHAKE_REFUSED = "handshake_refused";
|
|
10062
|
+
AGENT_ERROR_DISCONNECTED = "disconnected";
|
|
10063
|
+
AGENT_ERROR_NOT_CONNECTED = "not_connected";
|
|
10064
|
+
HANDSHAKE_TIMEOUT_MILLIS = 3e4;
|
|
10065
|
+
PONG_GRACE_INTERVALS = 2;
|
|
10066
|
+
BASE_CAPABILITIES = ["tunnel", "stats", "tunnel_control", "tunnel_update", "saved_config_update"];
|
|
10067
|
+
CAPABILITY_TERMINAL = "terminal";
|
|
10068
|
+
CAPABILITY_TERMINAL_SNAPSHOT = "terminal_snapshot";
|
|
10069
|
+
DISCONNECT_REASON_SHUTDOWN = "shutdown";
|
|
10070
|
+
DISCONNECT_MESSAGES = {
|
|
10071
|
+
revoked: "Credential revoked. Copy the new install command from the dashboard and run it to reconnect.",
|
|
10072
|
+
deleted: "Device deleted from the dashboard. Add it again to reconnect this machine.",
|
|
10073
|
+
replaced: "This device connected again from another session. Stopping this one."
|
|
10074
|
+
};
|
|
10075
|
+
}
|
|
10076
|
+
});
|
|
10077
|
+
|
|
10078
|
+
// src/devices/login/login_schema.ts
|
|
10079
|
+
var import_zod5, LoginStartedSchema, LoginStatus, LoginAnswerSchema, LoginErrorSchema, LoginErrorType;
|
|
10080
|
+
var init_login_schema = __esm({
|
|
10081
|
+
"src/devices/login/login_schema.ts"() {
|
|
10082
|
+
"use strict";
|
|
10083
|
+
init_cjs_shims();
|
|
10084
|
+
import_zod5 = require("zod");
|
|
10085
|
+
LoginStartedSchema = import_zod5.z.object({
|
|
10086
|
+
device_code: import_zod5.z.string().min(1),
|
|
10087
|
+
match_code: import_zod5.z.string().min(1).optional(),
|
|
10088
|
+
expires_in_seconds: import_zod5.z.number().int().positive(),
|
|
10089
|
+
poll_interval_seconds: import_zod5.z.number().int().positive()
|
|
10090
|
+
});
|
|
10091
|
+
LoginStatus = {
|
|
10092
|
+
Pending: "pending",
|
|
10093
|
+
MfaRequired: "mfa_required",
|
|
10094
|
+
Approved: "approved"
|
|
10095
|
+
};
|
|
10096
|
+
LoginAnswerSchema = import_zod5.z.object({
|
|
10097
|
+
status: import_zod5.z.enum([LoginStatus.Pending, LoginStatus.MfaRequired, LoginStatus.Approved]),
|
|
10098
|
+
token: import_zod5.z.string().min(1).optional(),
|
|
10099
|
+
device_agent_id: import_zod5.z.string().min(1).optional(),
|
|
10100
|
+
device_name: import_zod5.z.string().optional(),
|
|
10101
|
+
open_url: import_zod5.z.string().optional(),
|
|
10102
|
+
device_url: import_zod5.z.string().optional()
|
|
10103
|
+
});
|
|
10104
|
+
LoginErrorSchema = import_zod5.z.object({
|
|
10105
|
+
error_type: import_zod5.z.string(),
|
|
10106
|
+
errors: import_zod5.z.array(import_zod5.z.string()).default([])
|
|
10107
|
+
});
|
|
10108
|
+
LoginErrorType = {
|
|
10109
|
+
/** A wrong code or authenticator code, with attempts left. Asked again. */
|
|
10110
|
+
InvalidCode: "device_login_invalid_code",
|
|
10111
|
+
/** Expired, ended after too many wrong codes, or already used. */
|
|
10112
|
+
Expired: "device_login_expired",
|
|
10113
|
+
RateLimited: "device_login_rate_limited",
|
|
10114
|
+
InvalidClient: "device_login_invalid_client"
|
|
10115
|
+
};
|
|
10116
|
+
}
|
|
10117
|
+
});
|
|
10118
|
+
|
|
10119
|
+
// src/devices/login/loginApi.ts
|
|
10120
|
+
function buildDashboardHttpUrl(manage) {
|
|
10121
|
+
const address = (manage || DEFAULT_DASHBOARD).trim().replace(/\/+$/, "");
|
|
10122
|
+
const match = SCHEME_PATTERN.exec(address);
|
|
10123
|
+
const host = match ? address.slice(match[0].length) : address;
|
|
10124
|
+
const scheme = match && PLAIN_SCHEMES.has(match[1].toLowerCase()) ? "http" : "https";
|
|
10125
|
+
return `${scheme}://${host}${LOGIN_PATH}`;
|
|
10126
|
+
}
|
|
10127
|
+
function createDeviceLoginApi(manage, fetchImpl = fetch) {
|
|
10128
|
+
const baseUrl = buildDashboardHttpUrl(manage);
|
|
10129
|
+
async function post(path12, body, schema) {
|
|
10130
|
+
const response2 = await fetchImpl(`${baseUrl}/${path12}`, {
|
|
10131
|
+
method: "POST",
|
|
10132
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
10133
|
+
body: JSON.stringify(body)
|
|
10134
|
+
});
|
|
10135
|
+
const json = await readJson(response2);
|
|
10136
|
+
if (!response2.ok) {
|
|
10137
|
+
const refusal = LoginErrorSchema.safeParse(json);
|
|
10138
|
+
if (refusal.success && refusal.data.errors.length > 0) {
|
|
10139
|
+
throw new DeviceLoginApiError(response2.status, refusal.data.error_type, refusal.data.errors[0]);
|
|
10140
|
+
}
|
|
10141
|
+
throw new DeviceLoginApiError(
|
|
10142
|
+
response2.status,
|
|
10143
|
+
`http_${response2.status}`,
|
|
10144
|
+
`The dashboard answered HTTP ${response2.status}.`
|
|
10145
|
+
);
|
|
10146
|
+
}
|
|
10147
|
+
const answer = schema.safeParse(json);
|
|
10148
|
+
if (!answer.success) {
|
|
10149
|
+
throw new DeviceLoginApiError(
|
|
10150
|
+
response2.status,
|
|
10151
|
+
UNREADABLE_ANSWER,
|
|
10152
|
+
"The dashboard sent an answer this pinggy cannot read. Update pinggy and try again."
|
|
10153
|
+
);
|
|
10154
|
+
}
|
|
10155
|
+
return answer.data;
|
|
10156
|
+
}
|
|
10157
|
+
return {
|
|
10158
|
+
start: (request2) => post("start", request2, LoginStartedSchema),
|
|
10159
|
+
verifyCode: (deviceCode, code) => post("verify-code", { device_code: deviceCode, code }, LoginAnswerSchema),
|
|
10160
|
+
verifyMfa: (deviceCode, totp) => post("verify-mfa", { device_code: deviceCode, totp }, LoginAnswerSchema),
|
|
10161
|
+
poll: (deviceCode) => post("poll", { device_code: deviceCode }, LoginAnswerSchema)
|
|
10162
|
+
};
|
|
10163
|
+
}
|
|
10164
|
+
async function readJson(response2) {
|
|
10165
|
+
const text = await response2.text();
|
|
10166
|
+
if (!text) {
|
|
10167
|
+
return void 0;
|
|
10168
|
+
}
|
|
10169
|
+
try {
|
|
10170
|
+
return JSON.parse(text);
|
|
10171
|
+
} catch {
|
|
10172
|
+
return void 0;
|
|
10173
|
+
}
|
|
10174
|
+
}
|
|
10175
|
+
var DEVICE_LOGIN_CLIENT_TOKEN, DEFAULT_DASHBOARD, LOGIN_PATH, SCHEME_PATTERN, PLAIN_SCHEMES, DeviceLoginApiError, UNREADABLE_ANSWER;
|
|
10176
|
+
var init_loginApi = __esm({
|
|
10177
|
+
"src/devices/login/loginApi.ts"() {
|
|
10178
|
+
"use strict";
|
|
10179
|
+
init_cjs_shims();
|
|
10180
|
+
init_login_schema();
|
|
10181
|
+
DEVICE_LOGIN_CLIENT_TOKEN = "pinggy-cli-1";
|
|
10182
|
+
DEFAULT_DASHBOARD = "dashboard.pinggy.io";
|
|
10183
|
+
LOGIN_PATH = "/backend/api/v1/device-agent/login";
|
|
10184
|
+
SCHEME_PATTERN = /^(wss?|https?):\/\//i;
|
|
10185
|
+
PLAIN_SCHEMES = /* @__PURE__ */ new Set(["ws", "http"]);
|
|
10186
|
+
DeviceLoginApiError = class extends Error {
|
|
10187
|
+
constructor(status, errorType, message) {
|
|
10188
|
+
super(message);
|
|
10189
|
+
this.status = status;
|
|
10190
|
+
this.errorType = errorType;
|
|
10191
|
+
this.name = "DeviceLoginApiError";
|
|
10192
|
+
}
|
|
10193
|
+
};
|
|
10194
|
+
UNREADABLE_ANSWER = "invalid_answer";
|
|
10195
|
+
}
|
|
10196
|
+
});
|
|
10197
|
+
|
|
10198
|
+
// src/devices/login/prompts.ts
|
|
10199
|
+
function createTerminalPrompter(input, output) {
|
|
10200
|
+
return {
|
|
10201
|
+
async ask(question, signal) {
|
|
10202
|
+
const readline = (0, import_promises.createInterface)({ input, output, terminal: true });
|
|
10203
|
+
try {
|
|
10204
|
+
return await readline.question(question, signal ? { signal } : {});
|
|
10205
|
+
} finally {
|
|
10206
|
+
readline.close();
|
|
10207
|
+
}
|
|
10208
|
+
},
|
|
10209
|
+
async askHidden(question, signal) {
|
|
10210
|
+
output.write(question);
|
|
10211
|
+
const readline = (0, import_promises.createInterface)({ input, output: discardingOutput(), terminal: true });
|
|
10212
|
+
try {
|
|
10213
|
+
const answer = await readline.question("", signal ? { signal } : {});
|
|
10214
|
+
output.write("\n");
|
|
10215
|
+
return answer;
|
|
10216
|
+
} finally {
|
|
10217
|
+
readline.close();
|
|
10218
|
+
}
|
|
10219
|
+
}
|
|
10220
|
+
};
|
|
10221
|
+
}
|
|
10222
|
+
function isInteractiveTerminal() {
|
|
10223
|
+
return process.stdin.isTTY === true;
|
|
10224
|
+
}
|
|
10225
|
+
function discardingOutput() {
|
|
10226
|
+
return new import_stream.Writable({
|
|
10227
|
+
write(_chunk, _encoding, callback) {
|
|
10228
|
+
callback();
|
|
10229
|
+
}
|
|
10230
|
+
});
|
|
10231
|
+
}
|
|
10232
|
+
var import_promises, import_stream, terminalPrompter;
|
|
10233
|
+
var init_prompts = __esm({
|
|
10234
|
+
"src/devices/login/prompts.ts"() {
|
|
10235
|
+
"use strict";
|
|
10236
|
+
init_cjs_shims();
|
|
10237
|
+
import_promises = require("readline/promises");
|
|
10238
|
+
import_stream = require("stream");
|
|
10239
|
+
terminalPrompter = createTerminalPrompter(process.stdin, process.stdout);
|
|
10240
|
+
}
|
|
10241
|
+
});
|
|
10242
|
+
|
|
10243
|
+
// src/devices/login/deviceLogin.ts
|
|
10244
|
+
function machineDeviceLoginDependencies() {
|
|
10245
|
+
return {
|
|
10246
|
+
createApi: (manage) => createDeviceLoginApi(manage),
|
|
10247
|
+
prompter: terminalPrompter,
|
|
10248
|
+
isInteractive: isInteractiveTerminal,
|
|
10249
|
+
readIdentity: readDeviceIdentity,
|
|
10250
|
+
writeIdentity: writeDeviceIdentity,
|
|
10251
|
+
runAgent: (token, manage) => runDeviceAgent(token, manage),
|
|
10252
|
+
renderQr: async (url) => (await createQrCodes([url]))[0],
|
|
10253
|
+
machine: () => ({
|
|
10254
|
+
hostname: import_os9.default.hostname(),
|
|
10255
|
+
os: import_os9.default.platform(),
|
|
10256
|
+
arch: import_os9.default.arch(),
|
|
10257
|
+
agent_version: getVersion()
|
|
10258
|
+
}),
|
|
10259
|
+
print: (message) => printer_default.print(message),
|
|
10260
|
+
printError: (message) => printer_default.error(message),
|
|
10261
|
+
sleep: sleepUnlessAborted
|
|
10262
|
+
};
|
|
10263
|
+
}
|
|
10264
|
+
async function runDeviceLogin(options, dependencies = machineDeviceLoginDependencies()) {
|
|
10265
|
+
if (!dependencies.isInteractive()) {
|
|
10266
|
+
dependencies.printError(NO_TERMINAL_MESSAGE);
|
|
10267
|
+
return EXIT_FAILED;
|
|
10268
|
+
}
|
|
10269
|
+
const stored = dependencies.readIdentity();
|
|
10270
|
+
const manage = options.manage ?? stored?.server;
|
|
10271
|
+
if (stored) {
|
|
10272
|
+
const answer = await dependencies.prompter.ask(
|
|
10273
|
+
`This machine is already enrolled as ${stored.device_agent_id ?? "a device"} on ${stored.server}. Add it again as a new device? [y/N] `
|
|
10274
|
+
);
|
|
10275
|
+
if (!YES_ANSWER.test(answer.trim())) {
|
|
10276
|
+
return await dependencies.runAgent(stored.token, manage) ?? EXIT_OK;
|
|
10277
|
+
}
|
|
10278
|
+
}
|
|
10279
|
+
const email = (await dependencies.prompter.ask("Email: ")).trim();
|
|
10280
|
+
if (!email) {
|
|
10281
|
+
dependencies.printError("An email is required.");
|
|
10282
|
+
return EXIT_FAILED;
|
|
10283
|
+
}
|
|
10284
|
+
const api = dependencies.createApi(manage);
|
|
10285
|
+
let approved;
|
|
10286
|
+
let linkMinutes;
|
|
10287
|
+
try {
|
|
10288
|
+
const started = await api.start({
|
|
10289
|
+
email,
|
|
10290
|
+
client_token: DEVICE_LOGIN_CLIENT_TOKEN,
|
|
10291
|
+
...dependencies.machine(),
|
|
10292
|
+
...options.name ? { name: options.name } : {}
|
|
10293
|
+
});
|
|
10294
|
+
linkMinutes = Math.round(started.expires_in_seconds / SECONDS_PER_MINUTE);
|
|
10295
|
+
dependencies.print(`We sent a 6-digit code to ${email}. It works for ${linkMinutes} minutes.`);
|
|
10296
|
+
if (started.match_code) {
|
|
10297
|
+
dependencies.print("Type it below, or click Approve sign-in in the email.");
|
|
10298
|
+
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.`);
|
|
10299
|
+
}
|
|
10300
|
+
approved = await waitForApproval(api, started.device_code, started.poll_interval_seconds, dependencies);
|
|
10301
|
+
} catch (err) {
|
|
10302
|
+
dependencies.printError(messageOf(err, manage));
|
|
10303
|
+
return EXIT_FAILED;
|
|
10304
|
+
}
|
|
10305
|
+
if (!approved.token || !approved.device_agent_id) {
|
|
10306
|
+
dependencies.printError("The dashboard approved the sign-in without a device token. Update pinggy and try again.");
|
|
10307
|
+
return EXIT_FAILED;
|
|
10308
|
+
}
|
|
10309
|
+
dependencies.writeIdentity({
|
|
10310
|
+
device_agent_id: approved.device_agent_id,
|
|
10311
|
+
token: approved.token,
|
|
10312
|
+
server: manage || DEFAULT_SERVER2,
|
|
10313
|
+
enrolled_at: null
|
|
10314
|
+
});
|
|
10315
|
+
dependencies.print(`Signed in as ${email}. Added device ${approved.device_name ?? approved.device_agent_id}.`);
|
|
10316
|
+
await printDeviceLink(approved, linkMinutes, dependencies);
|
|
10317
|
+
return await dependencies.runAgent(approved.token, manage) ?? EXIT_OK;
|
|
10318
|
+
}
|
|
10319
|
+
async function waitForApproval(api, deviceCode, pollIntervalSeconds, dependencies) {
|
|
10320
|
+
const controller = new AbortController();
|
|
10321
|
+
const prompt = { showing: false };
|
|
10322
|
+
const typed = typeTheCode(api, deviceCode, controller.signal, prompt, dependencies);
|
|
10323
|
+
const polled = pollUntilApproved(
|
|
10324
|
+
api,
|
|
10325
|
+
deviceCode,
|
|
10326
|
+
pollIntervalSeconds * MILLIS_PER_SECOND2,
|
|
10327
|
+
controller.signal,
|
|
10328
|
+
dependencies
|
|
10329
|
+
);
|
|
10330
|
+
typed.catch(() => void 0);
|
|
10331
|
+
polled.catch(() => void 0);
|
|
10332
|
+
try {
|
|
10333
|
+
return await Promise.race([typed, polled]);
|
|
10334
|
+
} finally {
|
|
10335
|
+
controller.abort();
|
|
10336
|
+
if (prompt.showing) {
|
|
10337
|
+
dependencies.print("");
|
|
10338
|
+
}
|
|
10339
|
+
}
|
|
10340
|
+
}
|
|
10341
|
+
async function typeTheCode(api, deviceCode, signal, prompt, dependencies) {
|
|
10342
|
+
const ask = async (question, hidden = false) => {
|
|
10343
|
+
prompt.showing = true;
|
|
10344
|
+
try {
|
|
10345
|
+
const answer2 = hidden ? dependencies.prompter.askHidden(question, signal) : dependencies.prompter.ask(question, signal);
|
|
10346
|
+
return (await answer2).trim();
|
|
10347
|
+
} finally {
|
|
10348
|
+
prompt.showing = false;
|
|
10349
|
+
}
|
|
10350
|
+
};
|
|
10351
|
+
let answer = null;
|
|
10352
|
+
while (answer === null) {
|
|
10353
|
+
const code = await ask("Code from the email: ");
|
|
10354
|
+
if (code) {
|
|
10355
|
+
answer = await askAgainOnWrongAnswer(() => api.verifyCode(deviceCode, code), dependencies);
|
|
10356
|
+
}
|
|
10357
|
+
}
|
|
10358
|
+
while (answer.status === LoginStatus.MfaRequired) {
|
|
10359
|
+
const totp = await ask("Authenticator code: ", true);
|
|
10360
|
+
if (totp) {
|
|
10361
|
+
answer = await askAgainOnWrongAnswer(() => api.verifyMfa(deviceCode, totp), dependencies) ?? answer;
|
|
10362
|
+
}
|
|
10363
|
+
}
|
|
10364
|
+
if (answer.status === LoginStatus.Pending) {
|
|
10365
|
+
dependencies.print("Approved from the email link. Waiting for the dashboard to finish.");
|
|
10366
|
+
return waitUntilAborted(signal);
|
|
10367
|
+
}
|
|
10368
|
+
if (answer.status !== LoginStatus.Approved) {
|
|
10369
|
+
throw new DeviceLoginApiError(
|
|
10370
|
+
0,
|
|
10371
|
+
"invalid_answer",
|
|
10372
|
+
"The dashboard answered the code with something this pinggy cannot use. Update pinggy and try again."
|
|
10373
|
+
);
|
|
10374
|
+
}
|
|
10375
|
+
return answer;
|
|
10376
|
+
}
|
|
10377
|
+
async function askAgainOnWrongAnswer(verify, dependencies) {
|
|
10378
|
+
try {
|
|
10379
|
+
return await verify();
|
|
10380
|
+
} catch (err) {
|
|
10381
|
+
if (err instanceof DeviceLoginApiError && err.errorType === LoginErrorType.InvalidCode) {
|
|
10382
|
+
dependencies.printError(err.message);
|
|
10383
|
+
return null;
|
|
10384
|
+
}
|
|
10385
|
+
throw err;
|
|
10386
|
+
}
|
|
10387
|
+
}
|
|
10388
|
+
async function pollUntilApproved(api, deviceCode, intervalMillis, signal, dependencies) {
|
|
10389
|
+
while (!signal.aborted) {
|
|
10390
|
+
await dependencies.sleep(intervalMillis, signal);
|
|
10391
|
+
if (signal.aborted) {
|
|
10392
|
+
break;
|
|
10393
|
+
}
|
|
10394
|
+
try {
|
|
10395
|
+
const answer = await api.poll(deviceCode);
|
|
10396
|
+
if (answer.status === LoginStatus.Approved) {
|
|
10397
|
+
return answer;
|
|
10398
|
+
}
|
|
10399
|
+
} catch (err) {
|
|
10400
|
+
if (err instanceof DeviceLoginApiError && err.status > 0 && err.status < FIRST_SERVER_ERROR_STATUS) {
|
|
10401
|
+
throw err;
|
|
10402
|
+
}
|
|
10403
|
+
}
|
|
10404
|
+
}
|
|
10405
|
+
throw signal.reason ?? new Error("The poll was stopped.");
|
|
10406
|
+
}
|
|
10407
|
+
async function printDeviceLink(approved, linkMinutes, dependencies) {
|
|
10408
|
+
let link;
|
|
10409
|
+
if (approved.open_url) {
|
|
10410
|
+
link = approved.open_url;
|
|
10411
|
+
dependencies.print(`Open the device in a browser. This link signs you in once, within ${linkMinutes} minutes:`);
|
|
10412
|
+
} else if (approved.device_url) {
|
|
10413
|
+
link = approved.device_url;
|
|
10414
|
+
dependencies.print("Open the device in a browser:");
|
|
10415
|
+
} else {
|
|
10416
|
+
return;
|
|
10417
|
+
}
|
|
10418
|
+
dependencies.print(` ${link}`);
|
|
10419
|
+
try {
|
|
10420
|
+
dependencies.print(await dependencies.renderQr(link));
|
|
10421
|
+
} catch {
|
|
10422
|
+
}
|
|
10423
|
+
}
|
|
10424
|
+
function messageOf(err, manage) {
|
|
10425
|
+
if (err instanceof DeviceLoginApiError) {
|
|
10426
|
+
return err.message;
|
|
10427
|
+
}
|
|
10428
|
+
const cause = err?.cause;
|
|
10429
|
+
const causeText = cause instanceof Error ? `: ${cause.message}` : "";
|
|
10430
|
+
const detail = err instanceof Error ? `${err.message}${causeText}` : String(err);
|
|
10431
|
+
return `Could not reach the dashboard at ${buildDashboardHttpUrl(manage)} (${detail}).`;
|
|
10432
|
+
}
|
|
10433
|
+
function waitUntilAborted(signal) {
|
|
10434
|
+
return new Promise((_resolve, reject) => {
|
|
10435
|
+
const stop = () => reject(signal.reason ?? new Error("The wait was stopped."));
|
|
10436
|
+
if (signal.aborted) {
|
|
10437
|
+
stop();
|
|
10438
|
+
return;
|
|
10439
|
+
}
|
|
10440
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
10441
|
+
});
|
|
10442
|
+
}
|
|
10443
|
+
function sleepUnlessAborted(millis, signal) {
|
|
10444
|
+
return new Promise((resolve) => {
|
|
10445
|
+
if (signal.aborted) {
|
|
10446
|
+
resolve();
|
|
10447
|
+
return;
|
|
10448
|
+
}
|
|
10449
|
+
const onAbort = () => {
|
|
10450
|
+
clearTimeout(timer);
|
|
10451
|
+
resolve();
|
|
10452
|
+
};
|
|
10453
|
+
const timer = setTimeout(() => {
|
|
10454
|
+
signal.removeEventListener("abort", onAbort);
|
|
10455
|
+
resolve();
|
|
10456
|
+
}, millis);
|
|
10457
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
10458
|
+
});
|
|
10459
|
+
}
|
|
10460
|
+
var import_os9, EXIT_OK, EXIT_FAILED, DEFAULT_SERVER2, YES_ANSWER, SECONDS_PER_MINUTE, MILLIS_PER_SECOND2, FIRST_SERVER_ERROR_STATUS, NO_TERMINAL_MESSAGE;
|
|
10461
|
+
var init_deviceLogin = __esm({
|
|
10462
|
+
"src/devices/login/deviceLogin.ts"() {
|
|
10463
|
+
"use strict";
|
|
10464
|
+
init_cjs_shims();
|
|
10465
|
+
import_os9 = __toESM(require("os"), 1);
|
|
10466
|
+
init_printer();
|
|
10467
|
+
init_util();
|
|
10468
|
+
init_qrCodeGenerator();
|
|
10469
|
+
init_deviceAgent();
|
|
10470
|
+
init_deviceIdentity();
|
|
10471
|
+
init_loginApi();
|
|
10472
|
+
init_login_schema();
|
|
10473
|
+
init_prompts();
|
|
10474
|
+
EXIT_OK = 0;
|
|
10475
|
+
EXIT_FAILED = 1;
|
|
10476
|
+
DEFAULT_SERVER2 = "dashboard.pinggy.io";
|
|
10477
|
+
YES_ANSWER = /^y(es)?$/i;
|
|
10478
|
+
SECONDS_PER_MINUTE = 60;
|
|
10479
|
+
MILLIS_PER_SECOND2 = 1e3;
|
|
10480
|
+
FIRST_SERVER_ERROR_STATUS = 500;
|
|
10481
|
+
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>";
|
|
10482
|
+
}
|
|
10483
|
+
});
|
|
10484
|
+
|
|
10485
|
+
// src/devices/backgroundAgent.ts
|
|
10486
|
+
function machineBackgroundAgentDependencies() {
|
|
10487
|
+
return {
|
|
10488
|
+
readIdentity: readDeviceIdentity,
|
|
10489
|
+
findDaemon: getDaemonInfo,
|
|
10490
|
+
ensureDaemon: ensureDaemonRunning,
|
|
10491
|
+
client: (port) => new IPCClient(port, "cli"),
|
|
10492
|
+
verify: (token, manage) => startDeviceAgent(token, manage, {
|
|
10493
|
+
verifyOnly: true,
|
|
10494
|
+
reporter: verifyingReporter
|
|
10495
|
+
}).done,
|
|
10496
|
+
sleep: (millis) => new Promise((resolve) => setTimeout(resolve, millis)),
|
|
10497
|
+
now: () => Date.now(),
|
|
10498
|
+
print: (message) => printer_default.print(message),
|
|
10499
|
+
printSuccess: (message) => printer_default.success(message),
|
|
10500
|
+
printWarning: (message) => printer_default.warn(message),
|
|
10501
|
+
printError: (message) => printer_default.error(message),
|
|
10502
|
+
daemonLogPath: getDaemonLogPath
|
|
10503
|
+
};
|
|
10504
|
+
}
|
|
10505
|
+
function isAgentRunning(status) {
|
|
10506
|
+
return status.state !== DeviceAgentState.Stopped;
|
|
10507
|
+
}
|
|
10508
|
+
function differsFromStored(target, stored) {
|
|
10509
|
+
if (!stored?.token) return true;
|
|
10510
|
+
return stored.token !== target.token || stored.server !== (target.manage || DEFAULT_SERVER);
|
|
10511
|
+
}
|
|
10512
|
+
function olderDaemonMessage(daemon) {
|
|
10513
|
+
if (daemon.host === DaemonHost.APP) {
|
|
10514
|
+
return "The Pinggy app runs an older daemon. Update the Pinggy app, or quit it and run this again.";
|
|
10515
|
+
}
|
|
10516
|
+
return "The running daemon predates background devices. Run pinggy daemon stop, then retry. This stops all running tunnels.";
|
|
10517
|
+
}
|
|
10518
|
+
async function readBackgroundAgent(dependencies) {
|
|
10519
|
+
const daemon = dependencies.findDaemon();
|
|
10520
|
+
if (!daemon || !isIpcCompatible(daemon)) {
|
|
10521
|
+
return null;
|
|
10522
|
+
}
|
|
10523
|
+
try {
|
|
10524
|
+
return { daemon, status: await dependencies.client(daemon.port).getDeviceAgentStatus() };
|
|
10525
|
+
} catch (err) {
|
|
10526
|
+
logger.debug("No background device agent to read", { error: errorMessage(err) });
|
|
10527
|
+
return null;
|
|
10528
|
+
}
|
|
10529
|
+
}
|
|
10530
|
+
async function startAgentInBackground(target, dependencies) {
|
|
10531
|
+
if (differsFromStored(target, dependencies.readIdentity())) {
|
|
10532
|
+
const verified = await dependencies.verify(target.token, target.manage);
|
|
10533
|
+
if (verified.error || !verified.welcomed) {
|
|
10534
|
+
dependencies.printError(verified.error?.message ?? "The device did not connect. Nothing changed.");
|
|
10535
|
+
return EXIT_FAILED;
|
|
10536
|
+
}
|
|
10537
|
+
}
|
|
10538
|
+
dependencies.print("Starting the device agent in the background.");
|
|
10539
|
+
let daemon;
|
|
10540
|
+
try {
|
|
10541
|
+
daemon = await dependencies.ensureDaemon();
|
|
10542
|
+
} catch (err) {
|
|
10543
|
+
dependencies.printError(errorMessage(err));
|
|
10544
|
+
return EXIT_FAILED;
|
|
10545
|
+
}
|
|
10546
|
+
const client = dependencies.client(daemon.port);
|
|
10547
|
+
try {
|
|
10548
|
+
const started = await client.startDeviceAgent();
|
|
10549
|
+
if (isErrorResponse(started)) {
|
|
10550
|
+
dependencies.printError(started.message);
|
|
10551
|
+
return EXIT_FAILED;
|
|
10552
|
+
}
|
|
10553
|
+
} catch (err) {
|
|
10554
|
+
dependencies.printError(daemonFailureMessage(err, daemon));
|
|
10555
|
+
return EXIT_FAILED;
|
|
10556
|
+
}
|
|
10557
|
+
return waitUntilConnected(client, daemon, dependencies);
|
|
10558
|
+
}
|
|
10559
|
+
async function stopAgentInBackground(dependencies) {
|
|
10560
|
+
const reading = await readBackgroundAgent(dependencies);
|
|
10561
|
+
if (!reading || !isAgentRunning(reading.status)) {
|
|
10562
|
+
dependencies.print("No device agent is running in the background.");
|
|
10563
|
+
return EXIT_OK;
|
|
10564
|
+
}
|
|
10565
|
+
if (!await stopReadAgent(reading, dependencies)) {
|
|
10566
|
+
return EXIT_FAILED;
|
|
10567
|
+
}
|
|
10568
|
+
dependencies.printSuccess("Stopped. The device shows offline in the dashboard.");
|
|
10569
|
+
return EXIT_OK;
|
|
10570
|
+
}
|
|
10571
|
+
async function stopAgentBeforeRemove(dependencies) {
|
|
10572
|
+
const reading = await readBackgroundAgent(dependencies);
|
|
10573
|
+
if (!reading || !isAgentRunning(reading.status)) {
|
|
10574
|
+
return true;
|
|
10575
|
+
}
|
|
10576
|
+
return stopReadAgent(reading, dependencies);
|
|
10577
|
+
}
|
|
10578
|
+
async function foregroundRefusal(dependencies) {
|
|
10579
|
+
const reading = await readBackgroundAgent(dependencies);
|
|
10580
|
+
if (!reading || !isAgentRunning(reading.status)) {
|
|
10581
|
+
return null;
|
|
7420
10582
|
}
|
|
7421
|
-
|
|
7422
|
-
|
|
7423
|
-
|
|
7424
|
-
|
|
7425
|
-
|
|
7426
|
-
|
|
7427
|
-
|
|
7428
|
-
|
|
7429
|
-
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
|
-
console.error(`Unsupported platform: ${platform2}`);
|
|
7433
|
-
process.exit(1);
|
|
10583
|
+
const device = reading.status.deviceAgentId ? `device ${reading.status.deviceAgentId}` : "a device";
|
|
10584
|
+
return `This machine already runs as ${device} in the background. Stop it first: pinggy device stop`;
|
|
10585
|
+
}
|
|
10586
|
+
async function printBackgroundStatus(dependencies) {
|
|
10587
|
+
const reading = await readBackgroundAgent(dependencies);
|
|
10588
|
+
if (!reading) {
|
|
10589
|
+
return;
|
|
10590
|
+
}
|
|
10591
|
+
dependencies.print(` Background ${describeAgent(reading)}`);
|
|
10592
|
+
if (reading.status.lastError) {
|
|
10593
|
+
dependencies.print(` Last error ${reading.status.lastError.message}`);
|
|
7434
10594
|
}
|
|
7435
10595
|
}
|
|
7436
|
-
function
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
|
|
7441
|
-
|
|
7442
|
-
case
|
|
7443
|
-
|
|
7444
|
-
break;
|
|
7445
|
-
case "win32":
|
|
7446
|
-
uninstallWindows();
|
|
7447
|
-
break;
|
|
10596
|
+
function describeAgentForDaemonStatus(status) {
|
|
10597
|
+
switch (status.state) {
|
|
10598
|
+
case DeviceAgentState.Connected:
|
|
10599
|
+
return `connected as ${status.deviceAgentId}`;
|
|
10600
|
+
case DeviceAgentState.Connecting:
|
|
10601
|
+
return "connecting";
|
|
10602
|
+
case DeviceAgentState.Reconnecting:
|
|
10603
|
+
return "reconnecting";
|
|
7448
10604
|
default:
|
|
7449
|
-
|
|
7450
|
-
|
|
10605
|
+
return "not running";
|
|
10606
|
+
}
|
|
10607
|
+
}
|
|
10608
|
+
function describeAgent({ daemon, status }) {
|
|
10609
|
+
const host = daemon.host === DaemonHost.APP ? "inside the Pinggy app" : `in the daemon, PID ${daemon.pid}`;
|
|
10610
|
+
switch (status.state) {
|
|
10611
|
+
case DeviceAgentState.Connected:
|
|
10612
|
+
return `connected since ${status.connectedAt}, ${host}`;
|
|
10613
|
+
case DeviceAgentState.Connecting:
|
|
10614
|
+
return `connecting, ${host}`;
|
|
10615
|
+
case DeviceAgentState.Reconnecting:
|
|
10616
|
+
return `reconnecting, ${host}`;
|
|
10617
|
+
default:
|
|
10618
|
+
return "not running";
|
|
7451
10619
|
}
|
|
7452
10620
|
}
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
import_node_path2 = __toESM(require("path"), 1);
|
|
7461
|
-
import_node_child_process = require("child_process");
|
|
7462
|
-
init_logger();
|
|
7463
|
-
SERVICE_LABEL = "io.pinggy.agent";
|
|
7464
|
-
SYSTEMD_SERVICE_NAME = "pinggy";
|
|
7465
|
-
WINDOWS_TASK_NAME = "PinggyDaemon";
|
|
10621
|
+
async function stopReadAgent(reading, dependencies) {
|
|
10622
|
+
try {
|
|
10623
|
+
await dependencies.client(reading.daemon.port).stopDeviceAgent();
|
|
10624
|
+
return true;
|
|
10625
|
+
} catch (err) {
|
|
10626
|
+
dependencies.printError(`Could not stop the background device agent: ${errorMessage(err)}`);
|
|
10627
|
+
return false;
|
|
7466
10628
|
}
|
|
7467
|
-
});
|
|
7468
|
-
|
|
7469
|
-
// src/utils/helpMessages.ts
|
|
7470
|
-
function printDaemonHelp() {
|
|
7471
|
-
console.log("\nUsage: pinggy daemon <command>");
|
|
7472
|
-
console.log(" pinggy d <command>\n");
|
|
7473
|
-
console.log("Commands:");
|
|
7474
|
-
console.log(" start Start the daemon process");
|
|
7475
|
-
console.log(" stop Stop the daemon (stops all tunnels)");
|
|
7476
|
-
console.log(" status Show daemon PID and uptime");
|
|
7477
|
-
console.log("Tunnel operations:");
|
|
7478
|
-
console.log(" pinggy ps List running tunnels");
|
|
7479
|
-
console.log(" pinggy stop <name|id> Stop a specific tunnel");
|
|
7480
|
-
console.log(" pinggy attach <name|id> Re-attach TUI to a tunnel\n");
|
|
7481
10629
|
}
|
|
7482
|
-
function
|
|
7483
|
-
|
|
7484
|
-
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
console.log(" path Print daemon log path");
|
|
7488
|
-
console.log(" path <name|id> Print tunnel log path\n");
|
|
10630
|
+
function daemonFailureMessage(err, daemon) {
|
|
10631
|
+
if (err instanceof IPCHttpError && err.statusCode === HTTP_NOT_FOUND) {
|
|
10632
|
+
return olderDaemonMessage(daemon);
|
|
10633
|
+
}
|
|
10634
|
+
return `Could not reach the daemon: ${errorMessage(err)}`;
|
|
7489
10635
|
}
|
|
7490
|
-
function
|
|
7491
|
-
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
|
|
7499
|
-
|
|
10636
|
+
async function waitUntilConnected(client, daemon, dependencies) {
|
|
10637
|
+
const deadline = dependencies.now() + CONNECT_WAIT_MILLIS;
|
|
10638
|
+
for (; ; ) {
|
|
10639
|
+
let status;
|
|
10640
|
+
try {
|
|
10641
|
+
status = await client.getDeviceAgentStatus();
|
|
10642
|
+
} catch (err) {
|
|
10643
|
+
dependencies.printError(daemonFailureMessage(err, daemon));
|
|
10644
|
+
return EXIT_FAILED;
|
|
10645
|
+
}
|
|
10646
|
+
if (status.state === DeviceAgentState.Connected) {
|
|
10647
|
+
printRunning(status, daemon, dependencies);
|
|
10648
|
+
return EXIT_OK;
|
|
10649
|
+
}
|
|
10650
|
+
if (status.state === DeviceAgentState.Stopped) {
|
|
10651
|
+
dependencies.printError(status.lastError?.message ?? "The device agent stopped before it connected.");
|
|
10652
|
+
return EXIT_FAILED;
|
|
10653
|
+
}
|
|
10654
|
+
if (dependencies.now() >= deadline) {
|
|
10655
|
+
dependencies.printWarning("Still connecting in the background. Check with: pinggy device status");
|
|
10656
|
+
return EXIT_OK;
|
|
10657
|
+
}
|
|
10658
|
+
await dependencies.sleep(POLL_INTERVAL_MILLIS);
|
|
10659
|
+
}
|
|
7500
10660
|
}
|
|
7501
|
-
function
|
|
7502
|
-
|
|
7503
|
-
|
|
7504
|
-
|
|
7505
|
-
|
|
7506
|
-
|
|
7507
|
-
|
|
10661
|
+
function printRunning(status, daemon, dependencies) {
|
|
10662
|
+
dependencies.printSuccess(`Running in the background as device ${status.deviceAgentId}`);
|
|
10663
|
+
if (status.email) {
|
|
10664
|
+
dependencies.print(`Signed in as ${status.email}`);
|
|
10665
|
+
}
|
|
10666
|
+
if (status.deviceUrl) {
|
|
10667
|
+
dependencies.print("Open the device in a browser:");
|
|
10668
|
+
dependencies.print(` ${status.deviceUrl}`);
|
|
10669
|
+
}
|
|
10670
|
+
if (daemon.host === DaemonHost.APP) {
|
|
10671
|
+
dependencies.print("It runs inside the Pinggy app, and stops when the app quits.");
|
|
10672
|
+
}
|
|
10673
|
+
dependencies.print(`Logs: ${dependencies.daemonLogPath()}`);
|
|
10674
|
+
dependencies.print("Stop it with: pinggy device stop");
|
|
7508
10675
|
}
|
|
7509
|
-
var
|
|
7510
|
-
|
|
10676
|
+
var POLL_INTERVAL_MILLIS, CONNECT_WAIT_MILLIS, verifyingReporter;
|
|
10677
|
+
var init_backgroundAgent = __esm({
|
|
10678
|
+
"src/devices/backgroundAgent.ts"() {
|
|
7511
10679
|
"use strict";
|
|
7512
10680
|
init_cjs_shims();
|
|
10681
|
+
init_logger();
|
|
10682
|
+
init_printer();
|
|
10683
|
+
init_configDir();
|
|
10684
|
+
init_util();
|
|
10685
|
+
init_types();
|
|
10686
|
+
init_daemonManager();
|
|
10687
|
+
init_ipcClient();
|
|
10688
|
+
init_ipcRoutes();
|
|
10689
|
+
init_deviceIdentity();
|
|
10690
|
+
init_deviceAgent();
|
|
10691
|
+
init_deviceAgentReporter();
|
|
10692
|
+
init_deviceLogin();
|
|
10693
|
+
POLL_INTERVAL_MILLIS = 500;
|
|
10694
|
+
CONNECT_WAIT_MILLIS = 15e3;
|
|
10695
|
+
verifyingReporter = {
|
|
10696
|
+
connecting: () => void 0,
|
|
10697
|
+
connected: () => void 0,
|
|
10698
|
+
reconnecting: () => void 0,
|
|
10699
|
+
warn: (message) => printer_default.warn(message),
|
|
10700
|
+
// The caller prints the error the run ends with.
|
|
10701
|
+
failed: () => void 0,
|
|
10702
|
+
replacingDevice: printingReporter.replacingDevice
|
|
10703
|
+
};
|
|
7513
10704
|
}
|
|
7514
10705
|
});
|
|
7515
10706
|
|
|
@@ -7528,7 +10719,7 @@ async function handleDaemon(args) {
|
|
|
7528
10719
|
await handleDaemonStop();
|
|
7529
10720
|
return;
|
|
7530
10721
|
case "status":
|
|
7531
|
-
handleDaemonStatus();
|
|
10722
|
+
await handleDaemonStatus();
|
|
7532
10723
|
return;
|
|
7533
10724
|
case "service-install":
|
|
7534
10725
|
installService();
|
|
@@ -7574,16 +10765,20 @@ async function handleDaemonStop() {
|
|
|
7574
10765
|
printer_default.print(import_picocolors6.default.yellow("No daemon is running."));
|
|
7575
10766
|
return;
|
|
7576
10767
|
}
|
|
10768
|
+
const agent = await readBackgroundAgent(machineBackgroundAgentDependencies());
|
|
7577
10769
|
const result = await stopDaemon();
|
|
7578
10770
|
if (result.ok) {
|
|
7579
10771
|
printer_default.success("Daemon stopped.");
|
|
10772
|
+
if (agent && isAgentRunning(agent.status)) {
|
|
10773
|
+
printer_default.print("The background device agent stopped too. The device shows offline in the dashboard.");
|
|
10774
|
+
}
|
|
7580
10775
|
} else if (result.reason === DaemonHost.APP) {
|
|
7581
10776
|
printer_default.print(import_picocolors6.default.yellow("The Pinggy daemon is running inside the Pinggy desktop app. Please quit the Pinggy app to stop it."));
|
|
7582
10777
|
} else {
|
|
7583
10778
|
printer_default.error(result.error);
|
|
7584
10779
|
}
|
|
7585
10780
|
}
|
|
7586
|
-
function handleDaemonStatus() {
|
|
10781
|
+
async function handleDaemonStatus() {
|
|
7587
10782
|
const info = getDaemonInfo();
|
|
7588
10783
|
if (!info) {
|
|
7589
10784
|
printer_default.print(import_picocolors6.default.yellow("No daemon is running. Start with: pinggy daemon start"));
|
|
@@ -7604,6 +10799,10 @@ function handleDaemonStatus() {
|
|
|
7604
10799
|
if (!isIpcCompatible(info)) {
|
|
7605
10800
|
printer_default.print(import_picocolors6.default.yellow(` Warning: ${ipcMismatchMessage(info)}`));
|
|
7606
10801
|
}
|
|
10802
|
+
const agent = await readBackgroundAgent(machineBackgroundAgentDependencies());
|
|
10803
|
+
if (agent) {
|
|
10804
|
+
printer_default.print(` Device: ${describeAgentForDaemonStatus(agent.status)}`);
|
|
10805
|
+
}
|
|
7607
10806
|
}
|
|
7608
10807
|
var import_picocolors6;
|
|
7609
10808
|
var init_daemonCommandsHandler = __esm({
|
|
@@ -7618,6 +10817,7 @@ var init_daemonCommandsHandler = __esm({
|
|
|
7618
10817
|
init_serviceInstaller();
|
|
7619
10818
|
init_helpMessages();
|
|
7620
10819
|
init_util();
|
|
10820
|
+
init_backgroundAgent();
|
|
7621
10821
|
}
|
|
7622
10822
|
});
|
|
7623
10823
|
|
|
@@ -7883,8 +11083,8 @@ async function followFile(filePath) {
|
|
|
7883
11083
|
stream.on("error", () => {
|
|
7884
11084
|
});
|
|
7885
11085
|
};
|
|
7886
|
-
watcher = import_node_fs4.default.watch(filePath, { persistent: true }, (
|
|
7887
|
-
if (
|
|
11086
|
+
watcher = import_node_fs4.default.watch(filePath, { persistent: true }, (event2) => {
|
|
11087
|
+
if (event2 === "change") {
|
|
7888
11088
|
const stat = import_node_fs4.default.existsSync(filePath) ? import_node_fs4.default.statSync(filePath) : null;
|
|
7889
11089
|
if (!stat || stat.size < fileSize) {
|
|
7890
11090
|
fileSize = 0;
|
|
@@ -8060,6 +11260,161 @@ var init_restartCommand = __esm({
|
|
|
8060
11260
|
}
|
|
8061
11261
|
});
|
|
8062
11262
|
|
|
11263
|
+
// src/cli/subcommand/handlers/deviceCommand.ts
|
|
11264
|
+
function takeEnvironmentToken() {
|
|
11265
|
+
const value = process.env[DEVICE_AGENT_TOKEN_VARIABLE]?.trim();
|
|
11266
|
+
delete process.env[DEVICE_AGENT_TOKEN_VARIABLE];
|
|
11267
|
+
return value || void 0;
|
|
11268
|
+
}
|
|
11269
|
+
function resolveConnectTarget(flagToken, environmentToken, manage, stored) {
|
|
11270
|
+
if (flagToken) {
|
|
11271
|
+
return { token: flagToken, manage: manage ?? stored?.server };
|
|
11272
|
+
}
|
|
11273
|
+
if (environmentToken) {
|
|
11274
|
+
return { token: environmentToken, manage };
|
|
11275
|
+
}
|
|
11276
|
+
if (stored?.token) {
|
|
11277
|
+
return { token: stored.token, manage: manage ?? stored.server };
|
|
11278
|
+
}
|
|
11279
|
+
return null;
|
|
11280
|
+
}
|
|
11281
|
+
async function handleDevice(args, background = machineBackgroundAgentDependencies()) {
|
|
11282
|
+
const environmentToken = takeEnvironmentToken();
|
|
11283
|
+
if (args.length === 0) {
|
|
11284
|
+
printDeviceHelp();
|
|
11285
|
+
return;
|
|
11286
|
+
}
|
|
11287
|
+
const verb = args[0];
|
|
11288
|
+
const flagArgs = args.slice(1);
|
|
11289
|
+
const { values } = parseCliArgs(cliOptions, flagArgs);
|
|
11290
|
+
configureLogger(values);
|
|
11291
|
+
const inBackground = Boolean(values.b);
|
|
11292
|
+
switch (verb) {
|
|
11293
|
+
case DeviceVerb.Login:
|
|
11294
|
+
exitUnlessOk(await handleDeviceLogin(values.manage, values.name, inBackground, background));
|
|
11295
|
+
return;
|
|
11296
|
+
case DeviceVerb.Connect:
|
|
11297
|
+
exitUnlessOk(await handleDeviceConnect(
|
|
11298
|
+
values.token,
|
|
11299
|
+
environmentToken,
|
|
11300
|
+
values.manage,
|
|
11301
|
+
inBackground,
|
|
11302
|
+
background
|
|
11303
|
+
));
|
|
11304
|
+
return;
|
|
11305
|
+
case DeviceVerb.Stop:
|
|
11306
|
+
exitUnlessOk(await stopAgentInBackground(background));
|
|
11307
|
+
return;
|
|
11308
|
+
case DeviceVerb.Status:
|
|
11309
|
+
await handleDeviceStatus(background);
|
|
11310
|
+
return;
|
|
11311
|
+
case DeviceVerb.Remove:
|
|
11312
|
+
exitUnlessOk(await handleDeviceRemove(background));
|
|
11313
|
+
return;
|
|
11314
|
+
default:
|
|
11315
|
+
if (verb === "help" || verb === "--help" || verb === "-h") {
|
|
11316
|
+
printDeviceHelp();
|
|
11317
|
+
return;
|
|
11318
|
+
}
|
|
11319
|
+
printer_default.error(`Unknown command "${verb}". Valid commands: login, connect, stop, status, remove.`);
|
|
11320
|
+
process.exit(1);
|
|
11321
|
+
}
|
|
11322
|
+
}
|
|
11323
|
+
function exitUnlessOk(exitCode) {
|
|
11324
|
+
if (exitCode !== EXIT_OK) {
|
|
11325
|
+
process.exit(exitCode);
|
|
11326
|
+
}
|
|
11327
|
+
}
|
|
11328
|
+
function refuseDevicesSpelling(args) {
|
|
11329
|
+
printer_default.error(devicesSpellingMessage(args));
|
|
11330
|
+
process.exit(1);
|
|
11331
|
+
}
|
|
11332
|
+
function devicesSpellingMessage(args) {
|
|
11333
|
+
const verbs = Object.values(DeviceVerb);
|
|
11334
|
+
const suggestion = verbs.includes(args[0]) ? args[0] : "--help";
|
|
11335
|
+
return `"pinggy devices" is now "pinggy device". Run: pinggy device ${suggestion}`;
|
|
11336
|
+
}
|
|
11337
|
+
async function mayRunInForeground(background) {
|
|
11338
|
+
const refusal = await foregroundRefusal(background);
|
|
11339
|
+
if (refusal) {
|
|
11340
|
+
printer_default.error(refusal);
|
|
11341
|
+
return false;
|
|
11342
|
+
}
|
|
11343
|
+
return true;
|
|
11344
|
+
}
|
|
11345
|
+
async function handleDeviceLogin(manage, name, inBackground, background) {
|
|
11346
|
+
if (!inBackground) {
|
|
11347
|
+
if (!await mayRunInForeground(background)) return EXIT_FAILED;
|
|
11348
|
+
return runDeviceLogin({ manage, name });
|
|
11349
|
+
}
|
|
11350
|
+
return runDeviceLogin({ manage, name }, {
|
|
11351
|
+
...machineDeviceLoginDependencies(),
|
|
11352
|
+
runAgent: (token, agentManage) => startAgentInBackground({ token, manage: agentManage }, background)
|
|
11353
|
+
});
|
|
11354
|
+
}
|
|
11355
|
+
async function handleDeviceConnect(flagToken, environmentToken, manage, inBackground, background) {
|
|
11356
|
+
const target = resolveConnectTarget(flagToken, environmentToken, manage, background.readIdentity());
|
|
11357
|
+
if (!target) {
|
|
11358
|
+
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>");
|
|
11359
|
+
return EXIT_FAILED;
|
|
11360
|
+
}
|
|
11361
|
+
if (inBackground) {
|
|
11362
|
+
return startAgentInBackground(target, background);
|
|
11363
|
+
}
|
|
11364
|
+
if (!await mayRunInForeground(background)) return EXIT_FAILED;
|
|
11365
|
+
await runDeviceAgent(target.token, target.manage);
|
|
11366
|
+
return EXIT_OK;
|
|
11367
|
+
}
|
|
11368
|
+
async function handleDeviceStatus(background) {
|
|
11369
|
+
const identity = background.readIdentity();
|
|
11370
|
+
if (!identity) {
|
|
11371
|
+
printer_default.print("This machine is not enrolled. Run: pinggy device login");
|
|
11372
|
+
return;
|
|
11373
|
+
}
|
|
11374
|
+
printer_default.print(` Device id ${identity.device_agent_id ?? import_picocolors9.default.gray("not yet assigned")}`);
|
|
11375
|
+
printer_default.print(` Server ${identity.server}`);
|
|
11376
|
+
printer_default.print(` Token ${maskToken(identity.token)}`);
|
|
11377
|
+
printer_default.print(` Enrolled ${identity.enrolled_at ?? import_picocolors9.default.gray("never")}`);
|
|
11378
|
+
await printBackgroundStatus(background);
|
|
11379
|
+
}
|
|
11380
|
+
async function handleDeviceRemove(background) {
|
|
11381
|
+
if (!await stopAgentBeforeRemove(background)) {
|
|
11382
|
+
return EXIT_FAILED;
|
|
11383
|
+
}
|
|
11384
|
+
if (clearDeviceIdentity()) {
|
|
11385
|
+
printer_default.success("Local device credential removed.");
|
|
11386
|
+
printer_default.print(import_picocolors9.default.gray("The device still exists in the dashboard. Delete it there to revoke it."));
|
|
11387
|
+
} else {
|
|
11388
|
+
printer_default.print("This machine is not enrolled.");
|
|
11389
|
+
}
|
|
11390
|
+
return EXIT_OK;
|
|
11391
|
+
}
|
|
11392
|
+
var import_picocolors9, DeviceVerb, DEVICE_AGENT_TOKEN_VARIABLE;
|
|
11393
|
+
var init_deviceCommand = __esm({
|
|
11394
|
+
"src/cli/subcommand/handlers/deviceCommand.ts"() {
|
|
11395
|
+
"use strict";
|
|
11396
|
+
init_cjs_shims();
|
|
11397
|
+
import_picocolors9 = __toESM(require("picocolors"), 1);
|
|
11398
|
+
init_options();
|
|
11399
|
+
init_parseArgs();
|
|
11400
|
+
init_logger();
|
|
11401
|
+
init_printer();
|
|
11402
|
+
init_helpMessages();
|
|
11403
|
+
init_deviceAgent();
|
|
11404
|
+
init_deviceIdentity();
|
|
11405
|
+
init_deviceLogin();
|
|
11406
|
+
init_backgroundAgent();
|
|
11407
|
+
DeviceVerb = {
|
|
11408
|
+
Login: "login",
|
|
11409
|
+
Connect: "connect",
|
|
11410
|
+
Stop: "stop",
|
|
11411
|
+
Status: "status",
|
|
11412
|
+
Remove: "remove"
|
|
11413
|
+
};
|
|
11414
|
+
DEVICE_AGENT_TOKEN_VARIABLE = "DEVICE_AGENT_TOKEN";
|
|
11415
|
+
}
|
|
11416
|
+
});
|
|
11417
|
+
|
|
8063
11418
|
// src/cli/subcommand/subcommands.ts
|
|
8064
11419
|
function isSubcommand(rawArgs) {
|
|
8065
11420
|
return rawArgs.length > 0 && SUBCOMMAND_SET.has(rawArgs[0]);
|
|
@@ -8099,6 +11454,12 @@ async function handleSubcommand(rawArgs) {
|
|
|
8099
11454
|
case Subcommand.Restart:
|
|
8100
11455
|
await handleRestart(rest);
|
|
8101
11456
|
return;
|
|
11457
|
+
case Subcommand.Device:
|
|
11458
|
+
await handleDevice(rest);
|
|
11459
|
+
return;
|
|
11460
|
+
case Subcommand.Devices:
|
|
11461
|
+
refuseDevicesSpelling(rest);
|
|
11462
|
+
return;
|
|
8102
11463
|
}
|
|
8103
11464
|
}
|
|
8104
11465
|
function handleConfig(args) {
|
|
@@ -8339,6 +11700,7 @@ var init_subcommands = __esm({
|
|
|
8339
11700
|
init_logsCommand();
|
|
8340
11701
|
init_logCommand();
|
|
8341
11702
|
init_restartCommand();
|
|
11703
|
+
init_deviceCommand();
|
|
8342
11704
|
init_tunnelClient();
|
|
8343
11705
|
init_startCli();
|
|
8344
11706
|
init_helpMessages();
|
|
@@ -8392,7 +11754,7 @@ async function main() {
|
|
|
8392
11754
|
printer_default.fatal(error);
|
|
8393
11755
|
}
|
|
8394
11756
|
}
|
|
8395
|
-
var import_url2, import_process,
|
|
11757
|
+
var import_url2, import_process, import_fs13, currentFile, entryFile;
|
|
8396
11758
|
var init_main = __esm({
|
|
8397
11759
|
"src/main.ts"() {
|
|
8398
11760
|
"use strict";
|
|
@@ -8407,7 +11769,7 @@ var init_main = __esm({
|
|
|
8407
11769
|
init_handler();
|
|
8408
11770
|
import_url2 = require("url");
|
|
8409
11771
|
import_process = require("process");
|
|
8410
|
-
|
|
11772
|
+
import_fs13 = require("fs");
|
|
8411
11773
|
init_remoteManagement();
|
|
8412
11774
|
init_buildAndStartTunnel();
|
|
8413
11775
|
init_subcommands();
|
|
@@ -8417,7 +11779,7 @@ var init_main = __esm({
|
|
|
8417
11779
|
currentFile = (0, import_url2.fileURLToPath)(importMetaUrl);
|
|
8418
11780
|
entryFile = null;
|
|
8419
11781
|
try {
|
|
8420
|
-
entryFile = import_process.argv[1] ? (0,
|
|
11782
|
+
entryFile = import_process.argv[1] ? (0, import_fs13.realpathSync)(import_process.argv[1]) : null;
|
|
8421
11783
|
} catch (e) {
|
|
8422
11784
|
entryFile = null;
|
|
8423
11785
|
}
|
|
@@ -8428,8 +11790,8 @@ var init_main = __esm({
|
|
|
8428
11790
|
});
|
|
8429
11791
|
|
|
8430
11792
|
// src/daemon/ws/wsProtocol.ts
|
|
8431
|
-
function createTunnelEvent(tunnelId,
|
|
8432
|
-
return { type: "tunnel_event", tunnelId, event, payload };
|
|
11793
|
+
function createTunnelEvent(tunnelId, event2, payload) {
|
|
11794
|
+
return { type: "tunnel_event", tunnelId, event: event2, payload };
|
|
8433
11795
|
}
|
|
8434
11796
|
function parseClientMessage(raw) {
|
|
8435
11797
|
try {
|
|
@@ -8541,7 +11903,7 @@ function parseTunnelLogFilename(filename) {
|
|
|
8541
11903
|
const name = parts.slice(1, -1).join("__");
|
|
8542
11904
|
return { origin, name, tunnelId };
|
|
8543
11905
|
}
|
|
8544
|
-
var import_node_http2, import_node_fs6, import_node_path4,
|
|
11906
|
+
var import_node_http2, import_node_fs6, import_node_path4, import_ws4, VALID_ORIGINS, UUID_RE, IPCServer;
|
|
8545
11907
|
var init_ipcServer = __esm({
|
|
8546
11908
|
"src/daemon/ipc/ipcServer.ts"() {
|
|
8547
11909
|
"use strict";
|
|
@@ -8549,7 +11911,7 @@ var init_ipcServer = __esm({
|
|
|
8549
11911
|
import_node_http2 = __toESM(require("http"), 1);
|
|
8550
11912
|
import_node_fs6 = __toESM(require("fs"), 1);
|
|
8551
11913
|
import_node_path4 = __toESM(require("path"), 1);
|
|
8552
|
-
|
|
11914
|
+
import_ws4 = require("ws");
|
|
8553
11915
|
init_main();
|
|
8554
11916
|
init_logger();
|
|
8555
11917
|
init_tunnelLogger();
|
|
@@ -8561,7 +11923,7 @@ var init_ipcServer = __esm({
|
|
|
8561
11923
|
init_types();
|
|
8562
11924
|
init_daemonChild();
|
|
8563
11925
|
init_stateStore();
|
|
8564
|
-
VALID_ORIGINS = ["app", "cli", "remote"];
|
|
11926
|
+
VALID_ORIGINS = ["app", "cli", "remote", "device"];
|
|
8565
11927
|
UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8566
11928
|
IPCServer = class {
|
|
8567
11929
|
constructor() {
|
|
@@ -8569,10 +11931,11 @@ var init_ipcServer = __esm({
|
|
|
8569
11931
|
this.sessionCounter = 0;
|
|
8570
11932
|
this.onSessionDisconnect = null;
|
|
8571
11933
|
this.sessionTracker = null;
|
|
11934
|
+
this.deviceAgentHost = null;
|
|
8572
11935
|
this.ops = new TunnelOperations();
|
|
8573
11936
|
this.startedAt = Date.now();
|
|
8574
11937
|
this.server = import_node_http2.default.createServer(this.handleRequest.bind(this));
|
|
8575
|
-
this.wss = new
|
|
11938
|
+
this.wss = new import_ws4.WebSocketServer({ noServer: true });
|
|
8576
11939
|
this.routes = this.buildRoutes();
|
|
8577
11940
|
this.setupWebSocket();
|
|
8578
11941
|
}
|
|
@@ -8586,6 +11949,13 @@ var init_ipcServer = __esm({
|
|
|
8586
11949
|
setSessionTracker(st) {
|
|
8587
11950
|
this.sessionTracker = st;
|
|
8588
11951
|
}
|
|
11952
|
+
setDeviceAgentHost(host) {
|
|
11953
|
+
this.deviceAgentHost = host;
|
|
11954
|
+
}
|
|
11955
|
+
requireDeviceAgentHost() {
|
|
11956
|
+
if (!this.deviceAgentHost) throw new Error("The device agent is not available in this daemon yet");
|
|
11957
|
+
return this.deviceAgentHost;
|
|
11958
|
+
}
|
|
8589
11959
|
/**
|
|
8590
11960
|
* Get all active sessions (for SessionTracker inspection).
|
|
8591
11961
|
*/
|
|
@@ -8718,6 +12088,10 @@ var init_ipcServer = __esm({
|
|
|
8718
12088
|
}
|
|
8719
12089
|
return { daemon: daemonPath, tunnels };
|
|
8720
12090
|
},
|
|
12091
|
+
// The request body is never read: a token or a server in it must not reach the agent.
|
|
12092
|
+
[Route.GetDeviceAgent]: () => this.requireDeviceAgentHost().status(),
|
|
12093
|
+
[Route.StartDeviceAgent]: (_req, ctx) => this.requireDeviceAgentHost().start(ctx.origin),
|
|
12094
|
+
[Route.StopDeviceAgent]: () => this.requireDeviceAgentHost().stop(),
|
|
8721
12095
|
[Route.Shutdown]: () => {
|
|
8722
12096
|
logger.info("Daemon shutdown requested via IPC");
|
|
8723
12097
|
if (getDaemonHost() === DaemonHost.APP) {
|
|
@@ -8734,6 +12108,9 @@ var init_ipcServer = __esm({
|
|
|
8734
12108
|
logger.error(`Shutdown step "${label}" failed`, { error: msg });
|
|
8735
12109
|
}
|
|
8736
12110
|
};
|
|
12111
|
+
step("stopDeviceAgent", () => {
|
|
12112
|
+
void this.deviceAgentHost?.close();
|
|
12113
|
+
});
|
|
8737
12114
|
step("removeDaemonInfo", removeDaemonInfo);
|
|
8738
12115
|
step("clearDaemonState", clearDaemonState);
|
|
8739
12116
|
step("stopAllTunnels", () => TunnelManager.getInstance().stopAllTunnels());
|
|
@@ -8766,8 +12143,8 @@ var init_ipcServer = __esm({
|
|
|
8766
12143
|
if (paramMatch) {
|
|
8767
12144
|
try {
|
|
8768
12145
|
await this.readBody(req);
|
|
8769
|
-
const
|
|
8770
|
-
this.sendJson(res, 200,
|
|
12146
|
+
const response2 = await paramMatch.invoke();
|
|
12147
|
+
this.sendJson(res, 200, response2);
|
|
8771
12148
|
} catch (err) {
|
|
8772
12149
|
const msg = errorMessage(err);
|
|
8773
12150
|
logger.error("IPC handler error", { route: `${method} ${url}`, error: msg });
|
|
@@ -9000,9 +12377,9 @@ var init_ipcServer = __esm({
|
|
|
9000
12377
|
session.subscriptions.clear();
|
|
9001
12378
|
this.sessions.delete(session.id);
|
|
9002
12379
|
}
|
|
9003
|
-
sendEvent(session, tunnelId,
|
|
9004
|
-
if (session.ws.readyState ===
|
|
9005
|
-
const msg = createTunnelEvent(tunnelId,
|
|
12380
|
+
sendEvent(session, tunnelId, event2, payload) {
|
|
12381
|
+
if (session.ws.readyState === import_ws4.WebSocket.OPEN) {
|
|
12382
|
+
const msg = createTunnelEvent(tunnelId, event2, payload);
|
|
9006
12383
|
session.ws.send(JSON.stringify(msg));
|
|
9007
12384
|
}
|
|
9008
12385
|
}
|
|
@@ -9193,6 +12570,210 @@ var init_sessionTracker = __esm({
|
|
|
9193
12570
|
}
|
|
9194
12571
|
});
|
|
9195
12572
|
|
|
12573
|
+
// src/daemon/deviceAgentHost.ts
|
|
12574
|
+
function stoppedStatus() {
|
|
12575
|
+
return {
|
|
12576
|
+
state: DeviceAgentState.Stopped,
|
|
12577
|
+
deviceAgentId: null,
|
|
12578
|
+
server: null,
|
|
12579
|
+
email: null,
|
|
12580
|
+
deviceUrl: null,
|
|
12581
|
+
startedAt: null,
|
|
12582
|
+
connectedAt: null,
|
|
12583
|
+
startedBy: null,
|
|
12584
|
+
lastError: null
|
|
12585
|
+
};
|
|
12586
|
+
}
|
|
12587
|
+
function writeIdentityWhileOwned(identity) {
|
|
12588
|
+
const stored = readDeviceIdentity();
|
|
12589
|
+
if (stored && stored.token !== identity.token) {
|
|
12590
|
+
logger.info("Device agent left a newer device.json in place");
|
|
12591
|
+
return;
|
|
12592
|
+
}
|
|
12593
|
+
writeDeviceIdentity(identity);
|
|
12594
|
+
}
|
|
12595
|
+
function machineDeviceAgentHostDependencies(port, record) {
|
|
12596
|
+
return {
|
|
12597
|
+
readIdentity: readDeviceIdentity,
|
|
12598
|
+
startRun: (identity, reporter) => startDeviceAgent(identity.token, identity.server, {
|
|
12599
|
+
reporter,
|
|
12600
|
+
tunnelListSource: selfTunnelListSource(port),
|
|
12601
|
+
tunnelActions: selfTunnelActionDependencies(port),
|
|
12602
|
+
writeIdentity: writeIdentityWhileOwned
|
|
12603
|
+
}),
|
|
12604
|
+
recordRunning: record.recordRunning,
|
|
12605
|
+
recordStopped: record.recordStopped,
|
|
12606
|
+
now: () => /* @__PURE__ */ new Date()
|
|
12607
|
+
};
|
|
12608
|
+
}
|
|
12609
|
+
var NOT_ENROLLED_MESSAGE, DeviceAgentHost;
|
|
12610
|
+
var init_deviceAgentHost = __esm({
|
|
12611
|
+
"src/daemon/deviceAgentHost.ts"() {
|
|
12612
|
+
"use strict";
|
|
12613
|
+
init_cjs_shims();
|
|
12614
|
+
init_logger();
|
|
12615
|
+
init_deviceIdentity();
|
|
12616
|
+
init_deviceAgent();
|
|
12617
|
+
init_tunnelList();
|
|
12618
|
+
init_tunnelActions();
|
|
12619
|
+
init_types();
|
|
12620
|
+
init_ipcRoutes();
|
|
12621
|
+
NOT_ENROLLED_MESSAGE = "This machine is not enrolled. Sign it in first: pinggy device login";
|
|
12622
|
+
DeviceAgentHost = class {
|
|
12623
|
+
constructor(dependencies) {
|
|
12624
|
+
this.dependencies = dependencies;
|
|
12625
|
+
this.current = null;
|
|
12626
|
+
this.statusValue = stoppedStatus();
|
|
12627
|
+
/** Bumped on every start and stop. A run's reporter whose number is old belongs to a run that ended. */
|
|
12628
|
+
this.generation = 0;
|
|
12629
|
+
/** Set once the daemon shuts down. Nothing starts and nothing more reaches daemon-state.json. */
|
|
12630
|
+
this.closed = false;
|
|
12631
|
+
/** Start and stop chain on this, so 1 finishes before the next begins. */
|
|
12632
|
+
this.queue = Promise.resolve();
|
|
12633
|
+
}
|
|
12634
|
+
status() {
|
|
12635
|
+
return { ...this.statusValue };
|
|
12636
|
+
}
|
|
12637
|
+
/**
|
|
12638
|
+
* Starts the agent on what device.json holds now. A run on the same token and server is left alone.
|
|
12639
|
+
* A run on another is stopped first.
|
|
12640
|
+
*/
|
|
12641
|
+
start(startedBy) {
|
|
12642
|
+
return this.exclusive(async () => {
|
|
12643
|
+
if (this.closed) {
|
|
12644
|
+
return this.status();
|
|
12645
|
+
}
|
|
12646
|
+
const identity = this.dependencies.readIdentity();
|
|
12647
|
+
if (!identity?.token) {
|
|
12648
|
+
return newErrorResponse(ErrorCode.DeviceNotEnrolled, NOT_ENROLLED_MESSAGE);
|
|
12649
|
+
}
|
|
12650
|
+
const current = this.current;
|
|
12651
|
+
if (current && current.token === identity.token && current.server === identity.server) {
|
|
12652
|
+
return this.status();
|
|
12653
|
+
}
|
|
12654
|
+
await this.stopCurrent();
|
|
12655
|
+
this.launch(identity, startedBy);
|
|
12656
|
+
return this.status();
|
|
12657
|
+
});
|
|
12658
|
+
}
|
|
12659
|
+
/** Stops the run: the socket closes with 1000 and every shell ends. */
|
|
12660
|
+
stop() {
|
|
12661
|
+
return this.exclusive(async () => {
|
|
12662
|
+
await this.stopCurrent();
|
|
12663
|
+
return this.status();
|
|
12664
|
+
});
|
|
12665
|
+
}
|
|
12666
|
+
/**
|
|
12667
|
+
* The daemon is shutting down. Stops the run at once, outside the queue, and writes nothing to
|
|
12668
|
+
* daemon-state.json: the shutdown deletes that file, and a write after it would bring the file back.
|
|
12669
|
+
*/
|
|
12670
|
+
close() {
|
|
12671
|
+
this.closed = true;
|
|
12672
|
+
const current = this.current;
|
|
12673
|
+
this.current = null;
|
|
12674
|
+
this.generation += 1;
|
|
12675
|
+
this.statusValue = { ...this.statusValue, state: DeviceAgentState.Stopped, connectedAt: null };
|
|
12676
|
+
return current ? current.run.stop().then(() => void 0) : Promise.resolve();
|
|
12677
|
+
}
|
|
12678
|
+
exclusive(operation) {
|
|
12679
|
+
const result = this.queue.then(operation);
|
|
12680
|
+
this.queue = result.catch(() => void 0);
|
|
12681
|
+
return result;
|
|
12682
|
+
}
|
|
12683
|
+
launch(identity, startedBy) {
|
|
12684
|
+
this.generation += 1;
|
|
12685
|
+
const generation = this.generation;
|
|
12686
|
+
this.statusValue = {
|
|
12687
|
+
state: DeviceAgentState.Connecting,
|
|
12688
|
+
deviceAgentId: identity.device_agent_id,
|
|
12689
|
+
server: identity.server,
|
|
12690
|
+
email: null,
|
|
12691
|
+
deviceUrl: null,
|
|
12692
|
+
startedAt: this.dependencies.now().toISOString(),
|
|
12693
|
+
connectedAt: null,
|
|
12694
|
+
startedBy,
|
|
12695
|
+
lastError: null
|
|
12696
|
+
};
|
|
12697
|
+
const run2 = this.dependencies.startRun(identity, this.reporterFor(generation));
|
|
12698
|
+
this.current = { run: run2, token: identity.token, server: identity.server };
|
|
12699
|
+
this.dependencies.recordRunning(startedBy);
|
|
12700
|
+
logger.info("Device agent started in the daemon", { startedBy });
|
|
12701
|
+
void run2.done.then((end) => this.onRunEnded(generation, end));
|
|
12702
|
+
}
|
|
12703
|
+
async stopCurrent() {
|
|
12704
|
+
const current = this.current;
|
|
12705
|
+
if (!current) {
|
|
12706
|
+
return;
|
|
12707
|
+
}
|
|
12708
|
+
this.current = null;
|
|
12709
|
+
this.generation += 1;
|
|
12710
|
+
this.statusValue = { ...this.statusValue, state: DeviceAgentState.Stopped, connectedAt: null };
|
|
12711
|
+
if (!this.closed) {
|
|
12712
|
+
this.dependencies.recordStopped();
|
|
12713
|
+
}
|
|
12714
|
+
await current.run.stop();
|
|
12715
|
+
logger.info("Device agent stopped in the daemon");
|
|
12716
|
+
}
|
|
12717
|
+
/** The run ended on its own: the dashboard refused the credential or ended the device. */
|
|
12718
|
+
onRunEnded(generation, end) {
|
|
12719
|
+
if (generation !== this.generation) {
|
|
12720
|
+
return;
|
|
12721
|
+
}
|
|
12722
|
+
this.current = null;
|
|
12723
|
+
this.statusValue = {
|
|
12724
|
+
...this.statusValue,
|
|
12725
|
+
state: DeviceAgentState.Stopped,
|
|
12726
|
+
connectedAt: null,
|
|
12727
|
+
lastError: end.error
|
|
12728
|
+
};
|
|
12729
|
+
if (!this.closed) {
|
|
12730
|
+
this.dependencies.recordStopped();
|
|
12731
|
+
}
|
|
12732
|
+
}
|
|
12733
|
+
/** Logs every event, and keeps the latest for `GET /device`. Never logs the email or a payload. */
|
|
12734
|
+
reporterFor(generation) {
|
|
12735
|
+
const isCurrent = () => generation === this.generation;
|
|
12736
|
+
let welcomedOnce = false;
|
|
12737
|
+
const notConnected = () => welcomedOnce ? DeviceAgentState.Reconnecting : DeviceAgentState.Connecting;
|
|
12738
|
+
return {
|
|
12739
|
+
connecting: (server) => {
|
|
12740
|
+
if (!isCurrent()) return;
|
|
12741
|
+
this.statusValue = { ...this.statusValue, state: notConnected(), connectedAt: null };
|
|
12742
|
+
logger.info("Device agent connecting", { server });
|
|
12743
|
+
},
|
|
12744
|
+
connected: (welcome) => {
|
|
12745
|
+
if (!isCurrent()) return;
|
|
12746
|
+
welcomedOnce = true;
|
|
12747
|
+
this.statusValue = {
|
|
12748
|
+
...this.statusValue,
|
|
12749
|
+
state: DeviceAgentState.Connected,
|
|
12750
|
+
deviceAgentId: welcome.device_agent_id,
|
|
12751
|
+
email: welcome.email ?? null,
|
|
12752
|
+
deviceUrl: welcome.device_url ?? null,
|
|
12753
|
+
connectedAt: this.dependencies.now().toISOString()
|
|
12754
|
+
};
|
|
12755
|
+
logger.info("Device agent connected", { deviceAgentId: welcome.device_agent_id });
|
|
12756
|
+
},
|
|
12757
|
+
reconnecting: (delayMillis, attempt) => {
|
|
12758
|
+
if (!isCurrent()) return;
|
|
12759
|
+
this.statusValue = { ...this.statusValue, state: notConnected(), connectedAt: null };
|
|
12760
|
+
logger.warn("Device agent disconnected", { delayMillis, attempt });
|
|
12761
|
+
},
|
|
12762
|
+
warn: (message) => {
|
|
12763
|
+
logger.warn("Device agent warning", { message });
|
|
12764
|
+
},
|
|
12765
|
+
failed: (error) => {
|
|
12766
|
+
logger.error("Device agent ended", { code: error.code });
|
|
12767
|
+
},
|
|
12768
|
+
replacingDevice: (oldDeviceAgentId) => {
|
|
12769
|
+
logger.info("Device agent connects a new device in place of a stored one", { oldDeviceAgentId });
|
|
12770
|
+
}
|
|
12771
|
+
};
|
|
12772
|
+
}
|
|
12773
|
+
};
|
|
12774
|
+
}
|
|
12775
|
+
});
|
|
12776
|
+
|
|
9196
12777
|
// src/daemon/lifecycle/daemonChild.ts
|
|
9197
12778
|
var daemonChild_exports = {};
|
|
9198
12779
|
__export(daemonChild_exports, {
|
|
@@ -9201,6 +12782,8 @@ __export(daemonChild_exports, {
|
|
|
9201
12782
|
removeDaemonInfo: () => removeDaemonInfo,
|
|
9202
12783
|
runDaemonChild: () => runDaemonChild,
|
|
9203
12784
|
setDaemonSessionTracker: () => setDaemonSessionTracker,
|
|
12785
|
+
trackDeviceAgentStart: () => trackDeviceAgentStart,
|
|
12786
|
+
trackDeviceAgentStop: () => trackDeviceAgentStop,
|
|
9204
12787
|
trackIPCTunnelStart: () => trackIPCTunnelStart,
|
|
9205
12788
|
trackTunnelStart: () => trackTunnelStart,
|
|
9206
12789
|
trackTunnelStop: () => trackTunnelStop
|
|
@@ -9246,6 +12829,29 @@ function trackTunnelStop(tunnelId) {
|
|
|
9246
12829
|
persistDaemonState(daemonState);
|
|
9247
12830
|
sessionTrackerRef?.removeTunnel(tunnelId);
|
|
9248
12831
|
}
|
|
12832
|
+
function trackDeviceAgentStart(startedBy) {
|
|
12833
|
+
daemonState.deviceAgent = { startedAt: (/* @__PURE__ */ new Date()).toISOString(), startedBy };
|
|
12834
|
+
persistDaemonState(daemonState);
|
|
12835
|
+
}
|
|
12836
|
+
function trackDeviceAgentStop() {
|
|
12837
|
+
if (!daemonState.deviceAgent) return;
|
|
12838
|
+
delete daemonState.deviceAgent;
|
|
12839
|
+
persistDaemonState(daemonState);
|
|
12840
|
+
}
|
|
12841
|
+
async function restoreCrashedDeviceAgent(host, saved) {
|
|
12842
|
+
try {
|
|
12843
|
+
const result = await host.start(saved.startedBy);
|
|
12844
|
+
if (isErrorResponse(result)) {
|
|
12845
|
+
logger.warn("Crash recovery: device agent not restored", { reason: result.message });
|
|
12846
|
+
trackDeviceAgentStop();
|
|
12847
|
+
return;
|
|
12848
|
+
}
|
|
12849
|
+
logger.info("Crash recovery: device agent restored");
|
|
12850
|
+
} catch (err) {
|
|
12851
|
+
logger.error("Crash recovery: device agent failed to start", { error: errorMessage(err) });
|
|
12852
|
+
trackDeviceAgentStop();
|
|
12853
|
+
}
|
|
12854
|
+
}
|
|
9249
12855
|
function trackIPCTunnelStart(tunnelId, origin, mode = SessionMode.Detached) {
|
|
9250
12856
|
if (mode === SessionMode.Foreground) return;
|
|
9251
12857
|
const manager = TunnelManager.getInstance();
|
|
@@ -9359,11 +12965,16 @@ async function runDaemonChild(opts = {}) {
|
|
|
9359
12965
|
});
|
|
9360
12966
|
ipcServer.setSessionTracker(sessionTracker);
|
|
9361
12967
|
setDaemonSessionTracker(sessionTracker);
|
|
12968
|
+
let deviceAgentHost = null;
|
|
9362
12969
|
let cleanedUp = false;
|
|
9363
12970
|
const cleanup = () => {
|
|
9364
12971
|
if (cleanedUp) return;
|
|
9365
12972
|
cleanedUp = true;
|
|
9366
12973
|
logger.info("Daemon shutting down");
|
|
12974
|
+
try {
|
|
12975
|
+
void deviceAgentHost?.close();
|
|
12976
|
+
} catch {
|
|
12977
|
+
}
|
|
9367
12978
|
try {
|
|
9368
12979
|
removeDaemonInfo();
|
|
9369
12980
|
} catch {
|
|
@@ -9409,6 +13020,15 @@ async function runDaemonChild(opts = {}) {
|
|
|
9409
13020
|
}
|
|
9410
13021
|
try {
|
|
9411
13022
|
const port = await ipcServer.listen();
|
|
13023
|
+
deviceAgentHost = new DeviceAgentHost(machineDeviceAgentHostDependencies(port, {
|
|
13024
|
+
recordRunning: trackDeviceAgentStart,
|
|
13025
|
+
recordStopped: trackDeviceAgentStop
|
|
13026
|
+
}));
|
|
13027
|
+
ipcServer.setDeviceAgentHost(deviceAgentHost);
|
|
13028
|
+
const crashedDeviceAgent = loadDaemonState()?.deviceAgent;
|
|
13029
|
+
if (crashedDeviceAgent) {
|
|
13030
|
+
daemonState.deviceAgent = crashedDeviceAgent;
|
|
13031
|
+
}
|
|
9412
13032
|
const info = {
|
|
9413
13033
|
pid: process.pid,
|
|
9414
13034
|
port,
|
|
@@ -9432,6 +13052,9 @@ async function runDaemonChild(opts = {}) {
|
|
|
9432
13052
|
} else {
|
|
9433
13053
|
logger.info("No auto-start tunnels configured");
|
|
9434
13054
|
}
|
|
13055
|
+
if (crashedDeviceAgent) {
|
|
13056
|
+
await restoreCrashedDeviceAgent(deviceAgentHost, crashedDeviceAgent);
|
|
13057
|
+
}
|
|
9435
13058
|
logger.info("Daemon ready", { pid: process.pid, port });
|
|
9436
13059
|
return {
|
|
9437
13060
|
pid: process.pid,
|
|
@@ -9456,12 +13079,14 @@ var init_daemonChild = __esm({
|
|
|
9456
13079
|
init_ipcServer();
|
|
9457
13080
|
init_sessionTracker();
|
|
9458
13081
|
init_stateStore();
|
|
13082
|
+
init_deviceAgentHost();
|
|
9459
13083
|
init_TunnelManager();
|
|
9460
13084
|
init_logger();
|
|
9461
13085
|
init_configDir();
|
|
9462
13086
|
init_tunnelLogger();
|
|
9463
13087
|
init_configStore();
|
|
9464
13088
|
init_daemonConfig();
|
|
13089
|
+
init_types();
|
|
9465
13090
|
init_util();
|
|
9466
13091
|
init_ipcRoutes();
|
|
9467
13092
|
daemonState = { tunnels: [], lastUpdated: "" };
|
|
@@ -9688,12 +13313,12 @@ var init_daemonManager = __esm({
|
|
|
9688
13313
|
});
|
|
9689
13314
|
|
|
9690
13315
|
// src/daemon/ws/wsStream.ts
|
|
9691
|
-
var
|
|
13316
|
+
var import_ws5, WS_NORMAL_CLOSE, WsStream;
|
|
9692
13317
|
var init_wsStream = __esm({
|
|
9693
13318
|
"src/daemon/ws/wsStream.ts"() {
|
|
9694
13319
|
"use strict";
|
|
9695
13320
|
init_cjs_shims();
|
|
9696
|
-
|
|
13321
|
+
import_ws5 = require("ws");
|
|
9697
13322
|
init_logger();
|
|
9698
13323
|
init_util();
|
|
9699
13324
|
init_ipcRoutes();
|
|
@@ -9722,11 +13347,11 @@ var init_wsStream = __esm({
|
|
|
9722
13347
|
}
|
|
9723
13348
|
// Lifecycle
|
|
9724
13349
|
async ensureOpen() {
|
|
9725
|
-
if (this.ws && this.ws.readyState ===
|
|
13350
|
+
if (this.ws && this.ws.readyState === import_ws5.WebSocket.OPEN) return;
|
|
9726
13351
|
this.wsReady = new Promise((resolve) => {
|
|
9727
13352
|
this.wsResolve = resolve;
|
|
9728
13353
|
});
|
|
9729
|
-
this.ws = new
|
|
13354
|
+
this.ws = new import_ws5.WebSocket(this.getWsUrl());
|
|
9730
13355
|
this.ws.on("open", () => {
|
|
9731
13356
|
if (this.wsResolve) {
|
|
9732
13357
|
this.wsResolve();
|
|
@@ -9783,7 +13408,7 @@ var init_wsStream = __esm({
|
|
|
9783
13408
|
this.subscribedTunnels.clear();
|
|
9784
13409
|
}
|
|
9785
13410
|
isOpen() {
|
|
9786
|
-
return !!this.ws && this.ws.readyState ===
|
|
13411
|
+
return !!this.ws && this.ws.readyState === import_ws5.WebSocket.OPEN;
|
|
9787
13412
|
}
|
|
9788
13413
|
// Subscriptions
|
|
9789
13414
|
async subscribe(tunnelId, mode = SessionMode.Foreground) {
|
|
@@ -9801,7 +13426,7 @@ var init_wsStream = __esm({
|
|
|
9801
13426
|
unsubscribe(tunnelId) {
|
|
9802
13427
|
const wasSubscribed = this.subscribedTunnels.delete(tunnelId);
|
|
9803
13428
|
if (!wasSubscribed) return false;
|
|
9804
|
-
if (this.ws && this.ws.readyState ===
|
|
13429
|
+
if (this.ws && this.ws.readyState === import_ws5.WebSocket.OPEN) {
|
|
9805
13430
|
try {
|
|
9806
13431
|
const msg = { type: "unsubscribe", tunnelId };
|
|
9807
13432
|
this.ws.send(JSON.stringify(msg));
|
|
@@ -9895,8 +13520,8 @@ var init_wsStream = __esm({
|
|
|
9895
13520
|
return;
|
|
9896
13521
|
}
|
|
9897
13522
|
if (msg.type !== "tunnel_event") return;
|
|
9898
|
-
const { tunnelId, event, payload } = msg;
|
|
9899
|
-
switch (
|
|
13523
|
+
const { tunnelId, event: event2, payload } = msg;
|
|
13524
|
+
switch (event2) {
|
|
9900
13525
|
case "stats":
|
|
9901
13526
|
for (const cb of this.callbacks.stats) cb(tunnelId, payload.stats);
|
|
9902
13527
|
break;
|
|
@@ -9940,7 +13565,7 @@ var init_wsStream = __esm({
|
|
|
9940
13565
|
});
|
|
9941
13566
|
|
|
9942
13567
|
// src/daemon/daemonHealth.ts
|
|
9943
|
-
var RECONNECT_ATTEMPTS, RECONNECT_INTERVAL_MS, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, HEARTBEAT_FAILURE_THRESHOLD,
|
|
13568
|
+
var RECONNECT_ATTEMPTS, RECONNECT_INTERVAL_MS, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, HEARTBEAT_FAILURE_THRESHOLD, sleep3, DaemonHealth;
|
|
9944
13569
|
var init_daemonHealth = __esm({
|
|
9945
13570
|
"src/daemon/daemonHealth.ts"() {
|
|
9946
13571
|
"use strict";
|
|
@@ -9954,7 +13579,7 @@ var init_daemonHealth = __esm({
|
|
|
9954
13579
|
HEARTBEAT_INTERVAL_MS = 5e3;
|
|
9955
13580
|
HEARTBEAT_TIMEOUT_MS = 2e3;
|
|
9956
13581
|
HEARTBEAT_FAILURE_THRESHOLD = 2;
|
|
9957
|
-
|
|
13582
|
+
sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9958
13583
|
DaemonHealth = class {
|
|
9959
13584
|
constructor(getIpc, stream) {
|
|
9960
13585
|
this.getIpc = getIpc;
|
|
@@ -10033,7 +13658,7 @@ var init_daemonHealth = __esm({
|
|
|
10033
13658
|
} catch {
|
|
10034
13659
|
}
|
|
10035
13660
|
}
|
|
10036
|
-
await
|
|
13661
|
+
await sleep3(RECONNECT_INTERVAL_MS);
|
|
10037
13662
|
if (this.lost) return;
|
|
10038
13663
|
const info = getDaemonInfo();
|
|
10039
13664
|
if (!info) {
|
|
@@ -10241,6 +13866,20 @@ var init_tunnelClient = __esm({
|
|
|
10241
13866
|
this.assertClient();
|
|
10242
13867
|
await this.ipc.restartTunnel(tunnelId);
|
|
10243
13868
|
}
|
|
13869
|
+
// Device agent (`pinggy device connect -b`). The daemon reads the token and the server from
|
|
13870
|
+
// device.json, so none of these takes either.
|
|
13871
|
+
async getDeviceAgentStatus() {
|
|
13872
|
+
this.assertClient();
|
|
13873
|
+
return this.ipc.getDeviceAgentStatus();
|
|
13874
|
+
}
|
|
13875
|
+
async startDeviceAgent() {
|
|
13876
|
+
this.assertClient();
|
|
13877
|
+
return this.ipc.startDeviceAgent();
|
|
13878
|
+
}
|
|
13879
|
+
async stopDeviceAgent() {
|
|
13880
|
+
this.assertClient();
|
|
13881
|
+
return this.ipc.stopDeviceAgent();
|
|
13882
|
+
}
|
|
10244
13883
|
// Streaming — delegate to WsStream, guarded by daemon-lost
|
|
10245
13884
|
async attach(tunnelId, mode = SessionMode.Foreground) {
|
|
10246
13885
|
if (this.health.isLost()) return;
|
|
@@ -10380,6 +14019,7 @@ var init_tunnelClient = __esm({
|
|
|
10380
14019
|
// src/index.ts
|
|
10381
14020
|
var index_exports = {};
|
|
10382
14021
|
__export(index_exports, {
|
|
14022
|
+
DeviceAgentState: () => DeviceAgentState,
|
|
10383
14023
|
RemoteManagementUnauthorizedError: () => RemoteManagementUnauthorizedError,
|
|
10384
14024
|
TUNNEL_CONFIG_V1_KEYS: () => TUNNEL_CONFIG_V1_KEYS,
|
|
10385
14025
|
TunnelClient: () => TunnelClient,
|
|
@@ -10506,6 +14146,7 @@ init_TunnelManager();
|
|
|
10506
14146
|
init_handler();
|
|
10507
14147
|
init_tunnelClient();
|
|
10508
14148
|
init_daemonManager();
|
|
14149
|
+
init_ipcRoutes();
|
|
10509
14150
|
init_logger();
|
|
10510
14151
|
init_remoteManagement();
|
|
10511
14152
|
init_types();
|
|
@@ -10530,6 +14171,7 @@ verifyAndLoad().catch((err) => {
|
|
|
10530
14171
|
});
|
|
10531
14172
|
// Annotate the CommonJS export names for ESM import in node:
|
|
10532
14173
|
0 && (module.exports = {
|
|
14174
|
+
DeviceAgentState,
|
|
10533
14175
|
RemoteManagementUnauthorizedError,
|
|
10534
14176
|
TUNNEL_CONFIG_V1_KEYS,
|
|
10535
14177
|
TunnelClient,
|