pinggy 0.6.0 → 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 +335 -10
- package/CLAUDE.md +20 -2
- package/README.md +90 -0
- package/dist/TunnelManager-NGSUBZYI.js +13 -0
- package/dist/{TunnelTui-OZ6AVGTY.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-2HGC37SK.js → chunk-GESMZQHI.js} +47 -119
- package/dist/{chunk-76SOSXXR.js → chunk-MV56U3E6.js} +2 -2
- package/dist/{chunk-MT44NAXX.js → chunk-PD7HXVQ3.js} +1 -1
- package/dist/{chunk-J2U6P376.js → chunk-QNUVQ6KD.js} +204 -21
- package/dist/{chunk-4G6SPMMV.js → chunk-TG6LBCPG.js} +3486 -236
- package/dist/chunk-XZ64ZAGO.js +18 -0
- package/dist/{chunk-H5XJMFNT.js → chunk-Y435RMHQ.js} +7 -3
- package/dist/{configStore-LLWSTN6X.js → configStore-RR5ZU4E3.js} +3 -3
- package/dist/{daemonChild-CV2UTL6T.js → daemonChild-E73NFWKE.js} +13 -8
- package/dist/{daemonConfig-G6S46GPJ.js → daemonConfig-QVXQ47XH.js} +2 -2
- package/dist/index.cjs +3924 -281
- 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-WQ7NUQL4.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-I3NW6UXU.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({
|
|
@@ -687,14 +815,15 @@ var init_TunnelManager = __esm({
|
|
|
687
815
|
* @private
|
|
688
816
|
*/
|
|
689
817
|
async _createTunnelWithProcessedConfig(params) {
|
|
690
|
-
const
|
|
818
|
+
const tunnelConfig = { ...params.originalConfig, maxReconnectAttempts: 0 };
|
|
819
|
+
const tunnelLogName = params.tunnelName || tunnelConfig?.name;
|
|
691
820
|
const tunnelLogPath = getTunnelLogPath(params.tunnelid, params.origin, tunnelLogName);
|
|
692
821
|
maybeRotate(tunnelLogPath);
|
|
693
822
|
attachTunnelLogger(params.tunnelid, params.origin, tunnelLogName);
|
|
694
823
|
let instance;
|
|
695
824
|
try {
|
|
696
|
-
logger.debug("Creating tunnel instance with processed config",
|
|
697
|
-
instance = await import_pinggy2.TunnelInstance.create(
|
|
825
|
+
logger.debug("Creating tunnel instance with processed config", tunnelConfig);
|
|
826
|
+
instance = await import_pinggy2.TunnelInstance.create(tunnelConfig, {
|
|
698
827
|
enabled: true,
|
|
699
828
|
logLevel: mapToSdkLogLevel(getLogLevel()),
|
|
700
829
|
logFilePath: tunnelLogPath,
|
|
@@ -712,7 +841,7 @@ var init_TunnelManager = __esm({
|
|
|
712
841
|
tunnelName: params.tunnelName,
|
|
713
842
|
origin: params.origin,
|
|
714
843
|
instance,
|
|
715
|
-
tunnelConfig
|
|
844
|
+
tunnelConfig,
|
|
716
845
|
serve: params.serve,
|
|
717
846
|
warnings: [],
|
|
718
847
|
isStopped: false,
|
|
@@ -752,11 +881,12 @@ var init_TunnelManager = __esm({
|
|
|
752
881
|
try {
|
|
753
882
|
urls = await managed.instance.start();
|
|
754
883
|
} catch (error) {
|
|
755
|
-
|
|
884
|
+
const reason = errorMessage(error) || START_FAILED_MESSAGE;
|
|
885
|
+
logger.warn("Failed to start tunnel", { tunnelId, error: reason });
|
|
756
886
|
managed.isStopped = true;
|
|
757
887
|
managed.stoppedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
758
888
|
managed.lastError = {
|
|
759
|
-
message:
|
|
889
|
+
message: reason,
|
|
760
890
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
761
891
|
isFatal: true
|
|
762
892
|
};
|
|
@@ -927,6 +1057,9 @@ var init_TunnelManager = __esm({
|
|
|
927
1057
|
logger.error(`Tunnel "${tunnelId}" not found when fetching status`);
|
|
928
1058
|
throw new Error(`Tunnel "${tunnelId}" not found`);
|
|
929
1059
|
}
|
|
1060
|
+
if (managed.rebuild?.startsAfter) {
|
|
1061
|
+
return "starting" /* Starting */;
|
|
1062
|
+
}
|
|
930
1063
|
if (managed.isStopped) {
|
|
931
1064
|
return "exited";
|
|
932
1065
|
}
|
|
@@ -977,6 +1110,10 @@ var init_TunnelManager = __esm({
|
|
|
977
1110
|
logger.warn("Attempted to remove tunnel that is not stopped", { tunnelId });
|
|
978
1111
|
return false;
|
|
979
1112
|
}
|
|
1113
|
+
if (managed.rebuild) {
|
|
1114
|
+
logger.warn("Attempted to remove tunnel that is restarting", { tunnelId });
|
|
1115
|
+
return false;
|
|
1116
|
+
}
|
|
980
1117
|
this._cleanupTunnelRecords(managed);
|
|
981
1118
|
logger.info("Removed stopped tunnel records", { tunnelId, configId: managed.configId });
|
|
982
1119
|
return true;
|
|
@@ -1056,17 +1193,27 @@ var init_TunnelManager = __esm({
|
|
|
1056
1193
|
if (!managed) {
|
|
1057
1194
|
return Promise.reject(new Error(`Tunnel with configId "${configId}" not found`));
|
|
1058
1195
|
}
|
|
1059
|
-
return
|
|
1196
|
+
return this.currentConfig(managed);
|
|
1060
1197
|
}
|
|
1061
1198
|
if (tunnelId) {
|
|
1062
1199
|
const managed = this.tunnelsByTunnelId.get(tunnelId);
|
|
1063
1200
|
if (!managed) {
|
|
1064
1201
|
return Promise.reject(new Error(`Tunnel with tunnelId "${tunnelId}" not found`));
|
|
1065
1202
|
}
|
|
1066
|
-
return
|
|
1203
|
+
return this.currentConfig(managed);
|
|
1067
1204
|
}
|
|
1068
1205
|
return Promise.reject(new Error(`Either configId or tunnelId must be provided`));
|
|
1069
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
|
+
}
|
|
1070
1217
|
/**
|
|
1071
1218
|
* Stop a tunnel instance and wait for the stop to settle, capped by a
|
|
1072
1219
|
* timeout. Failure is logged, not thrown: the caller replaces the
|
|
@@ -1090,6 +1237,46 @@ var init_TunnelManager = __esm({
|
|
|
1090
1237
|
if (timer) clearTimeout(timer);
|
|
1091
1238
|
}
|
|
1092
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
|
+
}
|
|
1093
1280
|
/**
|
|
1094
1281
|
* Restarts a tunnel with its current configuration.
|
|
1095
1282
|
* This function will stop the tunnel if it's running and start it again.
|
|
@@ -1100,6 +1287,7 @@ var init_TunnelManager = __esm({
|
|
|
1100
1287
|
if (!existingTunnel) {
|
|
1101
1288
|
throw new Error(`Tunnel "${tunnelid}" not found`);
|
|
1102
1289
|
}
|
|
1290
|
+
this.beginRebuild(existingTunnel, true);
|
|
1103
1291
|
logger.info("Initiating tunnel restart", {
|
|
1104
1292
|
tunnelId: tunnelid,
|
|
1105
1293
|
configId: existingTunnel.configId
|
|
@@ -1111,11 +1299,7 @@ var init_TunnelManager = __esm({
|
|
|
1111
1299
|
const currentServe = existingTunnel.serve;
|
|
1112
1300
|
const autoReconnect = existingTunnel.autoReconnect || false;
|
|
1113
1301
|
const currentOrigin = existingTunnel.origin;
|
|
1114
|
-
|
|
1115
|
-
await this.stopInstanceGuarded(existingTunnel);
|
|
1116
|
-
}
|
|
1117
|
-
this.tunnelsByTunnelId.delete(tunnelid);
|
|
1118
|
-
this.tunnelsByConfigId.delete(existingTunnel.configId);
|
|
1302
|
+
await this.stopForRebuild(existingTunnel);
|
|
1119
1303
|
this.tunnelStats.delete(tunnelid);
|
|
1120
1304
|
this.tunnelStatsListeners.delete(tunnelid);
|
|
1121
1305
|
this.tunnelErrorListeners.delete(tunnelid);
|
|
@@ -1141,6 +1325,7 @@ var init_TunnelManager = __esm({
|
|
|
1141
1325
|
}
|
|
1142
1326
|
await this.startTunnel(newTunnel.tunnelid);
|
|
1143
1327
|
} catch (error) {
|
|
1328
|
+
this.failRebuild(existingTunnel, error);
|
|
1144
1329
|
logger.error("Failed to restart tunnel", {
|
|
1145
1330
|
tunnelid,
|
|
1146
1331
|
error: error instanceof Error ? error.message : "Unknown error"
|
|
@@ -1198,12 +1383,9 @@ var init_TunnelManager = __esm({
|
|
|
1198
1383
|
const currentStartedAt = existingTunnel.startedAt;
|
|
1199
1384
|
const currentStoppedAt = existingTunnel.stoppedAt;
|
|
1200
1385
|
const requestedServe = this.resolveServePath(newConfig);
|
|
1386
|
+
this.beginRebuild(existingTunnel, !isStopped);
|
|
1201
1387
|
try {
|
|
1202
|
-
|
|
1203
|
-
await this.stopInstanceGuarded(existingTunnel);
|
|
1204
|
-
}
|
|
1205
|
-
this.tunnelsByTunnelId.delete(currentTunnelId);
|
|
1206
|
-
this.tunnelsByConfigId.delete(currentTunnelConfigId);
|
|
1388
|
+
await this.stopForRebuild(existingTunnel);
|
|
1207
1389
|
const mergedBaseConfig = {
|
|
1208
1390
|
...newConfig,
|
|
1209
1391
|
configId,
|
|
@@ -1264,6 +1446,7 @@ var init_TunnelManager = __esm({
|
|
|
1264
1446
|
error: errorMessage(restoreError)
|
|
1265
1447
|
});
|
|
1266
1448
|
}
|
|
1449
|
+
this.failRebuild(existingTunnel, error);
|
|
1267
1450
|
throw error;
|
|
1268
1451
|
}
|
|
1269
1452
|
}
|
|
@@ -2074,120 +2257,6 @@ var init_TunnelManager = __esm({
|
|
|
2074
2257
|
}
|
|
2075
2258
|
});
|
|
2076
2259
|
|
|
2077
|
-
// src/types.ts
|
|
2078
|
-
function isErrorResponse(obj) {
|
|
2079
|
-
return typeof obj === "object" && obj !== null && "code" in obj && "message" in obj && typeof obj.message === "string" && Object.values(ErrorCode).includes(obj.code);
|
|
2080
|
-
}
|
|
2081
|
-
function newErrorResponse(codeOrError, message) {
|
|
2082
|
-
if (typeof codeOrError === "object") {
|
|
2083
|
-
return codeOrError;
|
|
2084
|
-
}
|
|
2085
|
-
return {
|
|
2086
|
-
code: codeOrError,
|
|
2087
|
-
message
|
|
2088
|
-
};
|
|
2089
|
-
}
|
|
2090
|
-
function NewResponseObject(data) {
|
|
2091
|
-
const encoder = new TextEncoder();
|
|
2092
|
-
const bytes = encoder.encode(JSON.stringify(data));
|
|
2093
|
-
return {
|
|
2094
|
-
response: bytes,
|
|
2095
|
-
requestid: "",
|
|
2096
|
-
command: "",
|
|
2097
|
-
error: false,
|
|
2098
|
-
errorresponse: {}
|
|
2099
|
-
};
|
|
2100
|
-
}
|
|
2101
|
-
function NewErrorResponseObject(errorResponse) {
|
|
2102
|
-
return {
|
|
2103
|
-
response: new Uint8Array(),
|
|
2104
|
-
requestid: "",
|
|
2105
|
-
command: "",
|
|
2106
|
-
error: true,
|
|
2107
|
-
errorresponse: errorResponse
|
|
2108
|
-
};
|
|
2109
|
-
}
|
|
2110
|
-
function newStatus(tunnelState, errorCode, errorMsg) {
|
|
2111
|
-
let assignedState = tunnelState;
|
|
2112
|
-
if (tunnelState === "live" /* Live */) {
|
|
2113
|
-
assignedState = "running" /* Running */;
|
|
2114
|
-
} else if (tunnelState === "idle" /* New */) {
|
|
2115
|
-
assignedState = "idle" /* New */;
|
|
2116
|
-
} else if (tunnelState === "closed" /* Closed */) {
|
|
2117
|
-
assignedState = "exited" /* Exited */;
|
|
2118
|
-
}
|
|
2119
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2120
|
-
return {
|
|
2121
|
-
state: assignedState,
|
|
2122
|
-
errorcode: errorCode,
|
|
2123
|
-
errormsg: errorMsg,
|
|
2124
|
-
createdtimestamp: now,
|
|
2125
|
-
starttimestamp: now,
|
|
2126
|
-
endtimestamp: now,
|
|
2127
|
-
warnings: []
|
|
2128
|
-
};
|
|
2129
|
-
}
|
|
2130
|
-
function newStats() {
|
|
2131
|
-
return {
|
|
2132
|
-
numLiveConnections: 0,
|
|
2133
|
-
numTotalConnections: 0,
|
|
2134
|
-
numTotalReqBytes: 0,
|
|
2135
|
-
numTotalResBytes: 0,
|
|
2136
|
-
numTotalTxBytes: 0,
|
|
2137
|
-
elapsedTime: 0
|
|
2138
|
-
};
|
|
2139
|
-
}
|
|
2140
|
-
var TunnelStateType, TunnelErrorCodeType, TunnelWarningCode, ErrorCode, RemoteManagementStatus;
|
|
2141
|
-
var init_types = __esm({
|
|
2142
|
-
"src/types.ts"() {
|
|
2143
|
-
"use strict";
|
|
2144
|
-
init_cjs_shims();
|
|
2145
|
-
TunnelStateType = /* @__PURE__ */ ((TunnelStateType2) => {
|
|
2146
|
-
TunnelStateType2["New"] = "idle";
|
|
2147
|
-
TunnelStateType2["Starting"] = "starting";
|
|
2148
|
-
TunnelStateType2["Running"] = "running";
|
|
2149
|
-
TunnelStateType2["Live"] = "live";
|
|
2150
|
-
TunnelStateType2["Closed"] = "closed";
|
|
2151
|
-
TunnelStateType2["Exited"] = "exited";
|
|
2152
|
-
return TunnelStateType2;
|
|
2153
|
-
})(TunnelStateType || {});
|
|
2154
|
-
TunnelErrorCodeType = /* @__PURE__ */ ((TunnelErrorCodeType2) => {
|
|
2155
|
-
TunnelErrorCodeType2["NonResponsive"] = "non_responsive";
|
|
2156
|
-
TunnelErrorCodeType2["FailedToConnect"] = "failed_to_connect";
|
|
2157
|
-
TunnelErrorCodeType2["ErrorInAdditionalForwarding"] = "additional_forwarding_error";
|
|
2158
|
-
TunnelErrorCodeType2["WebdebuggerError"] = "webdebugger_error";
|
|
2159
|
-
TunnelErrorCodeType2["NoError"] = "";
|
|
2160
|
-
return TunnelErrorCodeType2;
|
|
2161
|
-
})(TunnelErrorCodeType || {});
|
|
2162
|
-
TunnelWarningCode = /* @__PURE__ */ ((TunnelWarningCode2) => {
|
|
2163
|
-
TunnelWarningCode2["InvalidTunnelServePath"] = "INVALID_TUNNEL_SERVE_PATH";
|
|
2164
|
-
TunnelWarningCode2["UnknownWarning"] = "UNKNOWN_WARNING";
|
|
2165
|
-
return TunnelWarningCode2;
|
|
2166
|
-
})(TunnelWarningCode || {});
|
|
2167
|
-
ErrorCode = {
|
|
2168
|
-
InvalidRequestMethodError: "INVALID_REQUEST_METHOD",
|
|
2169
|
-
InvalidRequestBodyError: "COULD_NOT_READ_BODY",
|
|
2170
|
-
InternalServerError: "INTERNAL_SERVER_ERROR",
|
|
2171
|
-
InvalidBodyFormatError: "INVALID_DATA_FORMAT",
|
|
2172
|
-
ErrorStartingTunnel: "ERROR_STARTING_TUNNEL",
|
|
2173
|
-
TunnelNotFound: "TUNNEL_WITH_ID_OR_CONFIG_ID_NOT_FOUND",
|
|
2174
|
-
TunnelAlreadyRunningError: "TUNNEL_WITH_ID_OR_CONFIG_ID_ALREADY_RUNNING",
|
|
2175
|
-
WebsocketUpgradeFailError: "WEBSOCKET_UPGRADE_FAILED",
|
|
2176
|
-
RemoteManagementAlreadyRunning: "REMOTE_MANAGEMENT_ALREADY_RUNNING",
|
|
2177
|
-
RemoteManagementNotRunning: "REMOTE_MANAGEMENT_NOT_RUNNING",
|
|
2178
|
-
RemoteManagementDeserializationFailed: "REMOTE_MANAGEMENT_DESERIALIZATION_FAILED"
|
|
2179
|
-
};
|
|
2180
|
-
RemoteManagementStatus = {
|
|
2181
|
-
Connecting: "CONNECTING",
|
|
2182
|
-
Disconnecting: "DISCONNECTING",
|
|
2183
|
-
Reconnecting: "RECONNECTING",
|
|
2184
|
-
Running: "RUNNING",
|
|
2185
|
-
NotRunning: "NOT_RUNNING",
|
|
2186
|
-
Error: "ERROR"
|
|
2187
|
-
};
|
|
2188
|
-
}
|
|
2189
|
-
});
|
|
2190
|
-
|
|
2191
2260
|
// src/remote_management/remote_schema.ts
|
|
2192
2261
|
function pinggyOptionsToTunnelConfigV1(opts, configStoredInCli) {
|
|
2193
2262
|
const parsedTokens = opts.bearerTokenAuth ? Array.isArray(opts.bearerTokenAuth) ? opts.bearerTokenAuth : JSON.parse(opts.bearerTokenAuth) : [];
|
|
@@ -2504,6 +2573,9 @@ var init_handler = __esm({
|
|
|
2504
2573
|
}
|
|
2505
2574
|
if (managed?.lastError) {
|
|
2506
2575
|
status.lastError = managed.lastError;
|
|
2576
|
+
if (managed.lastError.isFatal) {
|
|
2577
|
+
status.errormsg = managed.lastError.message;
|
|
2578
|
+
}
|
|
2507
2579
|
}
|
|
2508
2580
|
} catch (e) {
|
|
2509
2581
|
}
|
|
@@ -2568,6 +2640,14 @@ var init_handler = __esm({
|
|
|
2568
2640
|
message: err instanceof Error ? err.message : fallback
|
|
2569
2641
|
});
|
|
2570
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
|
+
}
|
|
2571
2651
|
// --- Operations ---
|
|
2572
2652
|
async handleStart(config, noWait = false, origin = "cli") {
|
|
2573
2653
|
try {
|
|
@@ -2630,6 +2710,7 @@ var init_handler = __esm({
|
|
|
2630
2710
|
if (noWait) {
|
|
2631
2711
|
const existing = this.tunnelManager.getManagedTunnel(config.configid);
|
|
2632
2712
|
if (!existing.tunnelConfig) throw new Error("Invalid tunnel state before configuration update");
|
|
2713
|
+
if (existing.rebuild) return this.rebuildInProgress(existing.tunnelid);
|
|
2633
2714
|
if (!existing.isStopped) {
|
|
2634
2715
|
this.tunnelManager.updateConfig(updateOpts).catch((err) => {
|
|
2635
2716
|
logger.error("No-wait updateConfig failed", { configid: config.configid, err: String(err) });
|
|
@@ -2650,6 +2731,7 @@ var init_handler = __esm({
|
|
|
2650
2731
|
if (noWait) {
|
|
2651
2732
|
const existing = this.tunnelManager.getManagedTunnel(config.configId);
|
|
2652
2733
|
if (!existing.tunnelConfig) throw new Error("Invalid tunnel state before configuration update");
|
|
2734
|
+
if (existing.rebuild) return this.rebuildInProgress(existing.tunnelid);
|
|
2653
2735
|
if (!existing.isStopped) {
|
|
2654
2736
|
this.tunnelManager.updateConfig(config).catch((err) => {
|
|
2655
2737
|
logger.error("No-wait updateConfigV2 failed", { configId: config.configId, err: String(err) });
|
|
@@ -2748,6 +2830,7 @@ var init_handler = __esm({
|
|
|
2748
2830
|
if (noWait) {
|
|
2749
2831
|
const managed2 = this.tunnelManager.getManagedTunnel("", tunnelid);
|
|
2750
2832
|
if (!managed2?.tunnelConfig) throw new Error(`Tunnel config for ID "${tunnelid}" not found`);
|
|
2833
|
+
if (managed2.rebuild) return this.rebuildInProgress(tunnelid);
|
|
2751
2834
|
this.tunnelManager.restartTunnel(tunnelid).catch((err) => {
|
|
2752
2835
|
logger.error("No-wait restartTunnel failed", { tunnelid, err: String(err) });
|
|
2753
2836
|
});
|
|
@@ -2758,6 +2841,9 @@ var init_handler = __esm({
|
|
|
2758
2841
|
if (!managed?.tunnelConfig) throw new Error(`Tunnel config for ID "${tunnelid}" not found`);
|
|
2759
2842
|
return this.buildTunnelResponse(tunnelid, managed.tunnelConfig, managed.configId, managed.tunnelName, managed.serve);
|
|
2760
2843
|
} catch (err) {
|
|
2844
|
+
if (err instanceof TunnelRebuildInProgressError) {
|
|
2845
|
+
return this.rebuildInProgress(tunnelid);
|
|
2846
|
+
}
|
|
2761
2847
|
return this.error(ErrorCode.TunnelNotFound, err, "Failed to restart tunnel");
|
|
2762
2848
|
}
|
|
2763
2849
|
}
|
|
@@ -2800,7 +2886,7 @@ var init_handler = __esm({
|
|
|
2800
2886
|
});
|
|
2801
2887
|
|
|
2802
2888
|
// src/daemon/ipc/ipcRoutes.ts
|
|
2803
|
-
var IPC_VERSION, Route, DaemonHost, ShutdownStatus, SessionMode;
|
|
2889
|
+
var IPC_VERSION, Route, DaemonHost, ShutdownStatus, DeviceAgentState, SessionMode;
|
|
2804
2890
|
var init_ipcRoutes = __esm({
|
|
2805
2891
|
"src/daemon/ipc/ipcRoutes.ts"() {
|
|
2806
2892
|
"use strict";
|
|
@@ -2823,7 +2909,10 @@ var init_ipcRoutes = __esm({
|
|
|
2823
2909
|
SetLogLevel: "POST /loglevel",
|
|
2824
2910
|
GetTunnelLogging: "GET /config/tunnel-logging",
|
|
2825
2911
|
SetTunnelLogging: "POST /config/tunnel-logging",
|
|
2826
|
-
GetLogPaths: "GET /logs/paths"
|
|
2912
|
+
GetLogPaths: "GET /logs/paths",
|
|
2913
|
+
GetDeviceAgent: "GET /device",
|
|
2914
|
+
StartDeviceAgent: "POST /device/start",
|
|
2915
|
+
StopDeviceAgent: "POST /device/stop"
|
|
2827
2916
|
};
|
|
2828
2917
|
DaemonHost = {
|
|
2829
2918
|
CLI: "cli",
|
|
@@ -2833,6 +2922,12 @@ var init_ipcRoutes = __esm({
|
|
|
2833
2922
|
ShuttingDown: "shutting_down",
|
|
2834
2923
|
RefusedInApp: "refused_in_app"
|
|
2835
2924
|
};
|
|
2925
|
+
DeviceAgentState = {
|
|
2926
|
+
Stopped: "stopped",
|
|
2927
|
+
Connecting: "connecting",
|
|
2928
|
+
Connected: "connected",
|
|
2929
|
+
Reconnecting: "reconnecting"
|
|
2930
|
+
};
|
|
2836
2931
|
SessionMode = {
|
|
2837
2932
|
Foreground: "foreground",
|
|
2838
2933
|
Detached: "detached"
|
|
@@ -2843,9 +2938,11 @@ var init_ipcRoutes = __esm({
|
|
|
2843
2938
|
// src/daemon/ipc/ipcClient.ts
|
|
2844
2939
|
var ipcClient_exports = {};
|
|
2845
2940
|
__export(ipcClient_exports, {
|
|
2846
|
-
|
|
2941
|
+
HTTP_NOT_FOUND: () => HTTP_NOT_FOUND,
|
|
2942
|
+
IPCClient: () => IPCClient,
|
|
2943
|
+
IPCHttpError: () => IPCHttpError
|
|
2847
2944
|
});
|
|
2848
|
-
var import_node_http, REQUEST_TIMEOUT_MS, IPCClient;
|
|
2945
|
+
var import_node_http, REQUEST_TIMEOUT_MS, IPCHttpError, HTTP_NOT_FOUND, IPCClient;
|
|
2849
2946
|
var init_ipcClient = __esm({
|
|
2850
2947
|
"src/daemon/ipc/ipcClient.ts"() {
|
|
2851
2948
|
"use strict";
|
|
@@ -2853,6 +2950,14 @@ var init_ipcClient = __esm({
|
|
|
2853
2950
|
import_node_http = __toESM(require("http"), 1);
|
|
2854
2951
|
init_ipcRoutes();
|
|
2855
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;
|
|
2856
2961
|
IPCClient = class {
|
|
2857
2962
|
constructor(port, origin = "cli") {
|
|
2858
2963
|
this.port = port;
|
|
@@ -2919,6 +3024,15 @@ var init_ipcClient = __esm({
|
|
|
2919
3024
|
async resolveLogPath(q) {
|
|
2920
3025
|
return this.request("GET", `/logs/resolve?q=${encodeURIComponent(q)}`);
|
|
2921
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
|
+
}
|
|
2922
3036
|
/**
|
|
2923
3037
|
* Get the WebSocket URL for event streaming.
|
|
2924
3038
|
*/
|
|
@@ -2931,11 +3045,11 @@ var init_ipcClient = __esm({
|
|
|
2931
3045
|
call(key, body, timeoutMs) {
|
|
2932
3046
|
const spaceIdx = key.indexOf(" ");
|
|
2933
3047
|
const method = key.slice(0, spaceIdx);
|
|
2934
|
-
const
|
|
3048
|
+
const path12 = key.slice(spaceIdx + 1);
|
|
2935
3049
|
const payload = method === "GET" ? void 0 : JSON.stringify(body ?? {});
|
|
2936
|
-
return this.request(method,
|
|
3050
|
+
return this.request(method, path12, payload, timeoutMs);
|
|
2937
3051
|
}
|
|
2938
|
-
request(method,
|
|
3052
|
+
request(method, path12, body, timeoutMs) {
|
|
2939
3053
|
return new Promise((resolve, reject) => {
|
|
2940
3054
|
const headers = {
|
|
2941
3055
|
"X-Pinggy-Origin": this.origin
|
|
@@ -2948,7 +3062,7 @@ var init_ipcClient = __esm({
|
|
|
2948
3062
|
{
|
|
2949
3063
|
hostname: "127.0.0.1",
|
|
2950
3064
|
port: this.port,
|
|
2951
|
-
path:
|
|
3065
|
+
path: path12,
|
|
2952
3066
|
method,
|
|
2953
3067
|
headers,
|
|
2954
3068
|
timeout: timeoutMs ?? REQUEST_TIMEOUT_MS
|
|
@@ -2960,7 +3074,10 @@ var init_ipcClient = __esm({
|
|
|
2960
3074
|
const text = Buffer.concat(chunks).toString("utf-8");
|
|
2961
3075
|
const statusCode = res.statusCode ?? 0;
|
|
2962
3076
|
if (statusCode < 200 || statusCode >= 300) {
|
|
2963
|
-
reject(new
|
|
3077
|
+
reject(new IPCHttpError(
|
|
3078
|
+
statusCode,
|
|
3079
|
+
`Daemon returned HTTP ${statusCode}: ${text.slice(0, 200)}`
|
|
3080
|
+
));
|
|
2964
3081
|
return;
|
|
2965
3082
|
}
|
|
2966
3083
|
try {
|
|
@@ -3767,42 +3884,42 @@ var init_websocket_handlers = __esm({
|
|
|
3767
3884
|
const cmd = (req.command || "").toLowerCase();
|
|
3768
3885
|
const raw = this.safeParse(req.data);
|
|
3769
3886
|
try {
|
|
3770
|
-
let
|
|
3887
|
+
let response2;
|
|
3771
3888
|
switch (cmd) {
|
|
3772
3889
|
case WsCommand.Start: {
|
|
3773
|
-
|
|
3890
|
+
response2 = await this.handleStartReq(req, raw);
|
|
3774
3891
|
break;
|
|
3775
3892
|
}
|
|
3776
3893
|
case WsCommand.StartV2: {
|
|
3777
|
-
|
|
3894
|
+
response2 = await this.handleStartV2Req(req, raw);
|
|
3778
3895
|
break;
|
|
3779
3896
|
}
|
|
3780
3897
|
case WsCommand.Stop: {
|
|
3781
|
-
|
|
3898
|
+
response2 = await this.handleStopReq(req, raw);
|
|
3782
3899
|
break;
|
|
3783
3900
|
}
|
|
3784
3901
|
case WsCommand.Get: {
|
|
3785
|
-
|
|
3902
|
+
response2 = await this.handleGetReq(req, raw);
|
|
3786
3903
|
break;
|
|
3787
3904
|
}
|
|
3788
3905
|
case WsCommand.Restart: {
|
|
3789
|
-
|
|
3906
|
+
response2 = await this.handleRestartReq(req, raw);
|
|
3790
3907
|
break;
|
|
3791
3908
|
}
|
|
3792
3909
|
case WsCommand.UpdateConfig: {
|
|
3793
|
-
|
|
3910
|
+
response2 = await this.handleUpdateConfigReq(req, raw);
|
|
3794
3911
|
break;
|
|
3795
3912
|
}
|
|
3796
3913
|
case WsCommand.UpdateConfigV2: {
|
|
3797
|
-
|
|
3914
|
+
response2 = await this.handleUpdateConfigV2Req(req, raw);
|
|
3798
3915
|
break;
|
|
3799
3916
|
}
|
|
3800
3917
|
case WsCommand.List: {
|
|
3801
|
-
|
|
3918
|
+
response2 = await this.handleListReq(req);
|
|
3802
3919
|
break;
|
|
3803
3920
|
}
|
|
3804
3921
|
case WsCommand.ListV2: {
|
|
3805
|
-
|
|
3922
|
+
response2 = await this.handleListV2Req(req);
|
|
3806
3923
|
break;
|
|
3807
3924
|
}
|
|
3808
3925
|
case WsCommand.GetVersion: {
|
|
@@ -3815,8 +3932,8 @@ var init_websocket_handlers = __esm({
|
|
|
3815
3932
|
}
|
|
3816
3933
|
return this.sendError(ws, req, "Invalid command");
|
|
3817
3934
|
}
|
|
3818
|
-
logger.debug("Sending response", { command:
|
|
3819
|
-
this.sendResponse(ws,
|
|
3935
|
+
logger.debug("Sending response", { command: response2.command, requestid: response2.requestid });
|
|
3936
|
+
this.sendResponse(ws, response2);
|
|
3820
3937
|
} catch (e) {
|
|
3821
3938
|
if (e instanceof import_zod2.default.ZodError) {
|
|
3822
3939
|
logger.warn("Validation failed", { cmd, issues: e.issues });
|
|
@@ -4013,7 +4130,7 @@ function startRemoteManagement(remoteManagementConfig, tunnelHandler) {
|
|
|
4013
4130
|
resolve(getRemoteManagementState());
|
|
4014
4131
|
}
|
|
4015
4132
|
};
|
|
4016
|
-
const
|
|
4133
|
+
const runLoop2 = async () => {
|
|
4017
4134
|
const sigintHandler = () => {
|
|
4018
4135
|
_stopRequested = true;
|
|
4019
4136
|
};
|
|
@@ -4041,7 +4158,7 @@ function startRemoteManagement(remoteManagementConfig, tunnelHandler) {
|
|
|
4041
4158
|
process.removeListener("SIGINT", sigintHandler);
|
|
4042
4159
|
logger.info("Remote management stopped.");
|
|
4043
4160
|
};
|
|
4044
|
-
|
|
4161
|
+
runLoop2().catch((err) => settleOnce(err instanceof Error ? err : new Error(String(err))));
|
|
4045
4162
|
});
|
|
4046
4163
|
}
|
|
4047
4164
|
function getRemoteManagementState() {
|
|
@@ -4622,10 +4739,10 @@ function parseArgs2(finalConfig, remainingPositionals) {
|
|
|
4622
4739
|
}
|
|
4623
4740
|
function storeJson(config, saveconf) {
|
|
4624
4741
|
if (saveconf) {
|
|
4625
|
-
const
|
|
4742
|
+
const path12 = saveconf;
|
|
4626
4743
|
try {
|
|
4627
|
-
import_fs6.default.writeFileSync(
|
|
4628
|
-
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}`);
|
|
4629
4746
|
} catch (err) {
|
|
4630
4747
|
const msg = err instanceof Error ? err.message : String(err);
|
|
4631
4748
|
logger.error("Error loading configuration:", msg);
|
|
@@ -5134,7 +5251,11 @@ var init_configStore = __esm({
|
|
|
5134
5251
|
DaemonAlias: "d",
|
|
5135
5252
|
Logs: "logs",
|
|
5136
5253
|
Log: "log",
|
|
5137
|
-
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"
|
|
5138
5259
|
};
|
|
5139
5260
|
ConfigVerb = {
|
|
5140
5261
|
List: "list",
|
|
@@ -6764,7 +6885,7 @@ async function printAlreadyRunning(client, configId, label) {
|
|
|
6764
6885
|
async function startTunnel(client, config, opts) {
|
|
6765
6886
|
const result = await client.handleStartV2(config, false, opts.mode);
|
|
6766
6887
|
const prefix = opts.label ? `[${opts.label}] ` : "";
|
|
6767
|
-
const
|
|
6888
|
+
const fail2 = (reason) => {
|
|
6768
6889
|
const msg = `${prefix}Failed to start tunnel: ${reason}`;
|
|
6769
6890
|
if (opts.onError === "fatal") {
|
|
6770
6891
|
printer_default.error(msg);
|
|
@@ -6779,11 +6900,11 @@ async function startTunnel(client, config, opts) {
|
|
|
6779
6900
|
await printAlreadyRunning(client, config.configId, opts.label);
|
|
6780
6901
|
return null;
|
|
6781
6902
|
}
|
|
6782
|
-
return
|
|
6903
|
+
return fail2(result.message);
|
|
6783
6904
|
}
|
|
6784
6905
|
const lastError = result.status?.lastError;
|
|
6785
6906
|
if (lastError?.isFatal) {
|
|
6786
|
-
return
|
|
6907
|
+
return fail2(lastError.message);
|
|
6787
6908
|
}
|
|
6788
6909
|
return result;
|
|
6789
6910
|
}
|
|
@@ -7409,106 +7530,3177 @@ function uninstallWindows() {
|
|
|
7409
7530
|
console.log("No scheduled task found to remove.");
|
|
7410
7531
|
}
|
|
7411
7532
|
}
|
|
7412
|
-
function installService() {
|
|
7413
|
-
const platform2 = import_node_os.default.platform();
|
|
7414
|
-
const bin = resolveBinary();
|
|
7415
|
-
if (!("pkg" in process) && bin.program !== process.execPath) {
|
|
7416
|
-
console.warn(
|
|
7417
|
-
"Warning: Using npm-installed binary. The path may change if Node.js is updated.\nConsider using a standalone binary (pkg) for system services."
|
|
7418
|
-
);
|
|
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;
|
|
7419
10582
|
}
|
|
7420
|
-
|
|
7421
|
-
|
|
7422
|
-
|
|
7423
|
-
|
|
7424
|
-
|
|
7425
|
-
|
|
7426
|
-
|
|
7427
|
-
|
|
7428
|
-
|
|
7429
|
-
|
|
7430
|
-
|
|
7431
|
-
console.error(`Unsupported platform: ${platform2}`);
|
|
7432
|
-
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}`);
|
|
7433
10594
|
}
|
|
7434
10595
|
}
|
|
7435
|
-
function
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
|
|
7441
|
-
case
|
|
7442
|
-
|
|
7443
|
-
break;
|
|
7444
|
-
case "win32":
|
|
7445
|
-
uninstallWindows();
|
|
7446
|
-
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";
|
|
7447
10604
|
default:
|
|
7448
|
-
|
|
7449
|
-
|
|
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";
|
|
7450
10619
|
}
|
|
7451
10620
|
}
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
import_node_path2 = __toESM(require("path"), 1);
|
|
7460
|
-
import_node_child_process = require("child_process");
|
|
7461
|
-
init_logger();
|
|
7462
|
-
SERVICE_LABEL = "io.pinggy.agent";
|
|
7463
|
-
SYSTEMD_SERVICE_NAME = "pinggy";
|
|
7464
|
-
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;
|
|
7465
10628
|
}
|
|
7466
|
-
});
|
|
7467
|
-
|
|
7468
|
-
// src/utils/helpMessages.ts
|
|
7469
|
-
function printDaemonHelp() {
|
|
7470
|
-
console.log("\nUsage: pinggy daemon <command>");
|
|
7471
|
-
console.log(" pinggy d <command>\n");
|
|
7472
|
-
console.log("Commands:");
|
|
7473
|
-
console.log(" start Start the daemon process");
|
|
7474
|
-
console.log(" stop Stop the daemon (stops all tunnels)");
|
|
7475
|
-
console.log(" status Show daemon PID and uptime");
|
|
7476
|
-
console.log("Tunnel operations:");
|
|
7477
|
-
console.log(" pinggy ps List running tunnels");
|
|
7478
|
-
console.log(" pinggy stop <name|id> Stop a specific tunnel");
|
|
7479
|
-
console.log(" pinggy attach <name|id> Re-attach TUI to a tunnel\n");
|
|
7480
10629
|
}
|
|
7481
|
-
function
|
|
7482
|
-
|
|
7483
|
-
|
|
7484
|
-
|
|
7485
|
-
|
|
7486
|
-
console.log(" path Print daemon log path");
|
|
7487
|
-
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)}`;
|
|
7488
10635
|
}
|
|
7489
|
-
function
|
|
7490
|
-
|
|
7491
|
-
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
|
|
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
|
+
}
|
|
7499
10660
|
}
|
|
7500
|
-
function
|
|
7501
|
-
|
|
7502
|
-
|
|
7503
|
-
|
|
7504
|
-
|
|
7505
|
-
|
|
7506
|
-
|
|
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");
|
|
7507
10675
|
}
|
|
7508
|
-
var
|
|
7509
|
-
|
|
10676
|
+
var POLL_INTERVAL_MILLIS, CONNECT_WAIT_MILLIS, verifyingReporter;
|
|
10677
|
+
var init_backgroundAgent = __esm({
|
|
10678
|
+
"src/devices/backgroundAgent.ts"() {
|
|
7510
10679
|
"use strict";
|
|
7511
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
|
+
};
|
|
7512
10704
|
}
|
|
7513
10705
|
});
|
|
7514
10706
|
|
|
@@ -7527,7 +10719,7 @@ async function handleDaemon(args) {
|
|
|
7527
10719
|
await handleDaemonStop();
|
|
7528
10720
|
return;
|
|
7529
10721
|
case "status":
|
|
7530
|
-
handleDaemonStatus();
|
|
10722
|
+
await handleDaemonStatus();
|
|
7531
10723
|
return;
|
|
7532
10724
|
case "service-install":
|
|
7533
10725
|
installService();
|
|
@@ -7573,16 +10765,20 @@ async function handleDaemonStop() {
|
|
|
7573
10765
|
printer_default.print(import_picocolors6.default.yellow("No daemon is running."));
|
|
7574
10766
|
return;
|
|
7575
10767
|
}
|
|
10768
|
+
const agent = await readBackgroundAgent(machineBackgroundAgentDependencies());
|
|
7576
10769
|
const result = await stopDaemon();
|
|
7577
10770
|
if (result.ok) {
|
|
7578
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
|
+
}
|
|
7579
10775
|
} else if (result.reason === DaemonHost.APP) {
|
|
7580
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."));
|
|
7581
10777
|
} else {
|
|
7582
10778
|
printer_default.error(result.error);
|
|
7583
10779
|
}
|
|
7584
10780
|
}
|
|
7585
|
-
function handleDaemonStatus() {
|
|
10781
|
+
async function handleDaemonStatus() {
|
|
7586
10782
|
const info = getDaemonInfo();
|
|
7587
10783
|
if (!info) {
|
|
7588
10784
|
printer_default.print(import_picocolors6.default.yellow("No daemon is running. Start with: pinggy daemon start"));
|
|
@@ -7603,6 +10799,10 @@ function handleDaemonStatus() {
|
|
|
7603
10799
|
if (!isIpcCompatible(info)) {
|
|
7604
10800
|
printer_default.print(import_picocolors6.default.yellow(` Warning: ${ipcMismatchMessage(info)}`));
|
|
7605
10801
|
}
|
|
10802
|
+
const agent = await readBackgroundAgent(machineBackgroundAgentDependencies());
|
|
10803
|
+
if (agent) {
|
|
10804
|
+
printer_default.print(` Device: ${describeAgentForDaemonStatus(agent.status)}`);
|
|
10805
|
+
}
|
|
7606
10806
|
}
|
|
7607
10807
|
var import_picocolors6;
|
|
7608
10808
|
var init_daemonCommandsHandler = __esm({
|
|
@@ -7617,6 +10817,7 @@ var init_daemonCommandsHandler = __esm({
|
|
|
7617
10817
|
init_serviceInstaller();
|
|
7618
10818
|
init_helpMessages();
|
|
7619
10819
|
init_util();
|
|
10820
|
+
init_backgroundAgent();
|
|
7620
10821
|
}
|
|
7621
10822
|
});
|
|
7622
10823
|
|
|
@@ -7882,8 +11083,8 @@ async function followFile(filePath) {
|
|
|
7882
11083
|
stream.on("error", () => {
|
|
7883
11084
|
});
|
|
7884
11085
|
};
|
|
7885
|
-
watcher = import_node_fs4.default.watch(filePath, { persistent: true }, (
|
|
7886
|
-
if (
|
|
11086
|
+
watcher = import_node_fs4.default.watch(filePath, { persistent: true }, (event2) => {
|
|
11087
|
+
if (event2 === "change") {
|
|
7887
11088
|
const stat = import_node_fs4.default.existsSync(filePath) ? import_node_fs4.default.statSync(filePath) : null;
|
|
7888
11089
|
if (!stat || stat.size < fileSize) {
|
|
7889
11090
|
fileSize = 0;
|
|
@@ -8059,6 +11260,161 @@ var init_restartCommand = __esm({
|
|
|
8059
11260
|
}
|
|
8060
11261
|
});
|
|
8061
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
|
+
|
|
8062
11418
|
// src/cli/subcommand/subcommands.ts
|
|
8063
11419
|
function isSubcommand(rawArgs) {
|
|
8064
11420
|
return rawArgs.length > 0 && SUBCOMMAND_SET.has(rawArgs[0]);
|
|
@@ -8098,6 +11454,12 @@ async function handleSubcommand(rawArgs) {
|
|
|
8098
11454
|
case Subcommand.Restart:
|
|
8099
11455
|
await handleRestart(rest);
|
|
8100
11456
|
return;
|
|
11457
|
+
case Subcommand.Device:
|
|
11458
|
+
await handleDevice(rest);
|
|
11459
|
+
return;
|
|
11460
|
+
case Subcommand.Devices:
|
|
11461
|
+
refuseDevicesSpelling(rest);
|
|
11462
|
+
return;
|
|
8101
11463
|
}
|
|
8102
11464
|
}
|
|
8103
11465
|
function handleConfig(args) {
|
|
@@ -8338,6 +11700,7 @@ var init_subcommands = __esm({
|
|
|
8338
11700
|
init_logsCommand();
|
|
8339
11701
|
init_logCommand();
|
|
8340
11702
|
init_restartCommand();
|
|
11703
|
+
init_deviceCommand();
|
|
8341
11704
|
init_tunnelClient();
|
|
8342
11705
|
init_startCli();
|
|
8343
11706
|
init_helpMessages();
|
|
@@ -8391,7 +11754,7 @@ async function main() {
|
|
|
8391
11754
|
printer_default.fatal(error);
|
|
8392
11755
|
}
|
|
8393
11756
|
}
|
|
8394
|
-
var import_url2, import_process,
|
|
11757
|
+
var import_url2, import_process, import_fs13, currentFile, entryFile;
|
|
8395
11758
|
var init_main = __esm({
|
|
8396
11759
|
"src/main.ts"() {
|
|
8397
11760
|
"use strict";
|
|
@@ -8406,7 +11769,7 @@ var init_main = __esm({
|
|
|
8406
11769
|
init_handler();
|
|
8407
11770
|
import_url2 = require("url");
|
|
8408
11771
|
import_process = require("process");
|
|
8409
|
-
|
|
11772
|
+
import_fs13 = require("fs");
|
|
8410
11773
|
init_remoteManagement();
|
|
8411
11774
|
init_buildAndStartTunnel();
|
|
8412
11775
|
init_subcommands();
|
|
@@ -8416,7 +11779,7 @@ var init_main = __esm({
|
|
|
8416
11779
|
currentFile = (0, import_url2.fileURLToPath)(importMetaUrl);
|
|
8417
11780
|
entryFile = null;
|
|
8418
11781
|
try {
|
|
8419
|
-
entryFile = import_process.argv[1] ? (0,
|
|
11782
|
+
entryFile = import_process.argv[1] ? (0, import_fs13.realpathSync)(import_process.argv[1]) : null;
|
|
8420
11783
|
} catch (e) {
|
|
8421
11784
|
entryFile = null;
|
|
8422
11785
|
}
|
|
@@ -8427,8 +11790,8 @@ var init_main = __esm({
|
|
|
8427
11790
|
});
|
|
8428
11791
|
|
|
8429
11792
|
// src/daemon/ws/wsProtocol.ts
|
|
8430
|
-
function createTunnelEvent(tunnelId,
|
|
8431
|
-
return { type: "tunnel_event", tunnelId, event, payload };
|
|
11793
|
+
function createTunnelEvent(tunnelId, event2, payload) {
|
|
11794
|
+
return { type: "tunnel_event", tunnelId, event: event2, payload };
|
|
8432
11795
|
}
|
|
8433
11796
|
function parseClientMessage(raw) {
|
|
8434
11797
|
try {
|
|
@@ -8540,7 +11903,7 @@ function parseTunnelLogFilename(filename) {
|
|
|
8540
11903
|
const name = parts.slice(1, -1).join("__");
|
|
8541
11904
|
return { origin, name, tunnelId };
|
|
8542
11905
|
}
|
|
8543
|
-
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;
|
|
8544
11907
|
var init_ipcServer = __esm({
|
|
8545
11908
|
"src/daemon/ipc/ipcServer.ts"() {
|
|
8546
11909
|
"use strict";
|
|
@@ -8548,7 +11911,7 @@ var init_ipcServer = __esm({
|
|
|
8548
11911
|
import_node_http2 = __toESM(require("http"), 1);
|
|
8549
11912
|
import_node_fs6 = __toESM(require("fs"), 1);
|
|
8550
11913
|
import_node_path4 = __toESM(require("path"), 1);
|
|
8551
|
-
|
|
11914
|
+
import_ws4 = require("ws");
|
|
8552
11915
|
init_main();
|
|
8553
11916
|
init_logger();
|
|
8554
11917
|
init_tunnelLogger();
|
|
@@ -8560,7 +11923,7 @@ var init_ipcServer = __esm({
|
|
|
8560
11923
|
init_types();
|
|
8561
11924
|
init_daemonChild();
|
|
8562
11925
|
init_stateStore();
|
|
8563
|
-
VALID_ORIGINS = ["app", "cli", "remote"];
|
|
11926
|
+
VALID_ORIGINS = ["app", "cli", "remote", "device"];
|
|
8564
11927
|
UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8565
11928
|
IPCServer = class {
|
|
8566
11929
|
constructor() {
|
|
@@ -8568,10 +11931,11 @@ var init_ipcServer = __esm({
|
|
|
8568
11931
|
this.sessionCounter = 0;
|
|
8569
11932
|
this.onSessionDisconnect = null;
|
|
8570
11933
|
this.sessionTracker = null;
|
|
11934
|
+
this.deviceAgentHost = null;
|
|
8571
11935
|
this.ops = new TunnelOperations();
|
|
8572
11936
|
this.startedAt = Date.now();
|
|
8573
11937
|
this.server = import_node_http2.default.createServer(this.handleRequest.bind(this));
|
|
8574
|
-
this.wss = new
|
|
11938
|
+
this.wss = new import_ws4.WebSocketServer({ noServer: true });
|
|
8575
11939
|
this.routes = this.buildRoutes();
|
|
8576
11940
|
this.setupWebSocket();
|
|
8577
11941
|
}
|
|
@@ -8585,6 +11949,13 @@ var init_ipcServer = __esm({
|
|
|
8585
11949
|
setSessionTracker(st) {
|
|
8586
11950
|
this.sessionTracker = st;
|
|
8587
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
|
+
}
|
|
8588
11959
|
/**
|
|
8589
11960
|
* Get all active sessions (for SessionTracker inspection).
|
|
8590
11961
|
*/
|
|
@@ -8717,6 +12088,10 @@ var init_ipcServer = __esm({
|
|
|
8717
12088
|
}
|
|
8718
12089
|
return { daemon: daemonPath, tunnels };
|
|
8719
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(),
|
|
8720
12095
|
[Route.Shutdown]: () => {
|
|
8721
12096
|
logger.info("Daemon shutdown requested via IPC");
|
|
8722
12097
|
if (getDaemonHost() === DaemonHost.APP) {
|
|
@@ -8733,6 +12108,9 @@ var init_ipcServer = __esm({
|
|
|
8733
12108
|
logger.error(`Shutdown step "${label}" failed`, { error: msg });
|
|
8734
12109
|
}
|
|
8735
12110
|
};
|
|
12111
|
+
step("stopDeviceAgent", () => {
|
|
12112
|
+
void this.deviceAgentHost?.close();
|
|
12113
|
+
});
|
|
8736
12114
|
step("removeDaemonInfo", removeDaemonInfo);
|
|
8737
12115
|
step("clearDaemonState", clearDaemonState);
|
|
8738
12116
|
step("stopAllTunnels", () => TunnelManager.getInstance().stopAllTunnels());
|
|
@@ -8765,8 +12143,8 @@ var init_ipcServer = __esm({
|
|
|
8765
12143
|
if (paramMatch) {
|
|
8766
12144
|
try {
|
|
8767
12145
|
await this.readBody(req);
|
|
8768
|
-
const
|
|
8769
|
-
this.sendJson(res, 200,
|
|
12146
|
+
const response2 = await paramMatch.invoke();
|
|
12147
|
+
this.sendJson(res, 200, response2);
|
|
8770
12148
|
} catch (err) {
|
|
8771
12149
|
const msg = errorMessage(err);
|
|
8772
12150
|
logger.error("IPC handler error", { route: `${method} ${url}`, error: msg });
|
|
@@ -8999,9 +12377,9 @@ var init_ipcServer = __esm({
|
|
|
8999
12377
|
session.subscriptions.clear();
|
|
9000
12378
|
this.sessions.delete(session.id);
|
|
9001
12379
|
}
|
|
9002
|
-
sendEvent(session, tunnelId,
|
|
9003
|
-
if (session.ws.readyState ===
|
|
9004
|
-
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);
|
|
9005
12383
|
session.ws.send(JSON.stringify(msg));
|
|
9006
12384
|
}
|
|
9007
12385
|
}
|
|
@@ -9192,6 +12570,210 @@ var init_sessionTracker = __esm({
|
|
|
9192
12570
|
}
|
|
9193
12571
|
});
|
|
9194
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
|
+
|
|
9195
12777
|
// src/daemon/lifecycle/daemonChild.ts
|
|
9196
12778
|
var daemonChild_exports = {};
|
|
9197
12779
|
__export(daemonChild_exports, {
|
|
@@ -9200,6 +12782,8 @@ __export(daemonChild_exports, {
|
|
|
9200
12782
|
removeDaemonInfo: () => removeDaemonInfo,
|
|
9201
12783
|
runDaemonChild: () => runDaemonChild,
|
|
9202
12784
|
setDaemonSessionTracker: () => setDaemonSessionTracker,
|
|
12785
|
+
trackDeviceAgentStart: () => trackDeviceAgentStart,
|
|
12786
|
+
trackDeviceAgentStop: () => trackDeviceAgentStop,
|
|
9203
12787
|
trackIPCTunnelStart: () => trackIPCTunnelStart,
|
|
9204
12788
|
trackTunnelStart: () => trackTunnelStart,
|
|
9205
12789
|
trackTunnelStop: () => trackTunnelStop
|
|
@@ -9245,6 +12829,29 @@ function trackTunnelStop(tunnelId) {
|
|
|
9245
12829
|
persistDaemonState(daemonState);
|
|
9246
12830
|
sessionTrackerRef?.removeTunnel(tunnelId);
|
|
9247
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
|
+
}
|
|
9248
12855
|
function trackIPCTunnelStart(tunnelId, origin, mode = SessionMode.Detached) {
|
|
9249
12856
|
if (mode === SessionMode.Foreground) return;
|
|
9250
12857
|
const manager = TunnelManager.getInstance();
|
|
@@ -9358,11 +12965,16 @@ async function runDaemonChild(opts = {}) {
|
|
|
9358
12965
|
});
|
|
9359
12966
|
ipcServer.setSessionTracker(sessionTracker);
|
|
9360
12967
|
setDaemonSessionTracker(sessionTracker);
|
|
12968
|
+
let deviceAgentHost = null;
|
|
9361
12969
|
let cleanedUp = false;
|
|
9362
12970
|
const cleanup = () => {
|
|
9363
12971
|
if (cleanedUp) return;
|
|
9364
12972
|
cleanedUp = true;
|
|
9365
12973
|
logger.info("Daemon shutting down");
|
|
12974
|
+
try {
|
|
12975
|
+
void deviceAgentHost?.close();
|
|
12976
|
+
} catch {
|
|
12977
|
+
}
|
|
9366
12978
|
try {
|
|
9367
12979
|
removeDaemonInfo();
|
|
9368
12980
|
} catch {
|
|
@@ -9408,6 +13020,15 @@ async function runDaemonChild(opts = {}) {
|
|
|
9408
13020
|
}
|
|
9409
13021
|
try {
|
|
9410
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
|
+
}
|
|
9411
13032
|
const info = {
|
|
9412
13033
|
pid: process.pid,
|
|
9413
13034
|
port,
|
|
@@ -9431,6 +13052,9 @@ async function runDaemonChild(opts = {}) {
|
|
|
9431
13052
|
} else {
|
|
9432
13053
|
logger.info("No auto-start tunnels configured");
|
|
9433
13054
|
}
|
|
13055
|
+
if (crashedDeviceAgent) {
|
|
13056
|
+
await restoreCrashedDeviceAgent(deviceAgentHost, crashedDeviceAgent);
|
|
13057
|
+
}
|
|
9434
13058
|
logger.info("Daemon ready", { pid: process.pid, port });
|
|
9435
13059
|
return {
|
|
9436
13060
|
pid: process.pid,
|
|
@@ -9455,12 +13079,14 @@ var init_daemonChild = __esm({
|
|
|
9455
13079
|
init_ipcServer();
|
|
9456
13080
|
init_sessionTracker();
|
|
9457
13081
|
init_stateStore();
|
|
13082
|
+
init_deviceAgentHost();
|
|
9458
13083
|
init_TunnelManager();
|
|
9459
13084
|
init_logger();
|
|
9460
13085
|
init_configDir();
|
|
9461
13086
|
init_tunnelLogger();
|
|
9462
13087
|
init_configStore();
|
|
9463
13088
|
init_daemonConfig();
|
|
13089
|
+
init_types();
|
|
9464
13090
|
init_util();
|
|
9465
13091
|
init_ipcRoutes();
|
|
9466
13092
|
daemonState = { tunnels: [], lastUpdated: "" };
|
|
@@ -9687,12 +13313,12 @@ var init_daemonManager = __esm({
|
|
|
9687
13313
|
});
|
|
9688
13314
|
|
|
9689
13315
|
// src/daemon/ws/wsStream.ts
|
|
9690
|
-
var
|
|
13316
|
+
var import_ws5, WS_NORMAL_CLOSE, WsStream;
|
|
9691
13317
|
var init_wsStream = __esm({
|
|
9692
13318
|
"src/daemon/ws/wsStream.ts"() {
|
|
9693
13319
|
"use strict";
|
|
9694
13320
|
init_cjs_shims();
|
|
9695
|
-
|
|
13321
|
+
import_ws5 = require("ws");
|
|
9696
13322
|
init_logger();
|
|
9697
13323
|
init_util();
|
|
9698
13324
|
init_ipcRoutes();
|
|
@@ -9721,11 +13347,11 @@ var init_wsStream = __esm({
|
|
|
9721
13347
|
}
|
|
9722
13348
|
// Lifecycle
|
|
9723
13349
|
async ensureOpen() {
|
|
9724
|
-
if (this.ws && this.ws.readyState ===
|
|
13350
|
+
if (this.ws && this.ws.readyState === import_ws5.WebSocket.OPEN) return;
|
|
9725
13351
|
this.wsReady = new Promise((resolve) => {
|
|
9726
13352
|
this.wsResolve = resolve;
|
|
9727
13353
|
});
|
|
9728
|
-
this.ws = new
|
|
13354
|
+
this.ws = new import_ws5.WebSocket(this.getWsUrl());
|
|
9729
13355
|
this.ws.on("open", () => {
|
|
9730
13356
|
if (this.wsResolve) {
|
|
9731
13357
|
this.wsResolve();
|
|
@@ -9782,7 +13408,7 @@ var init_wsStream = __esm({
|
|
|
9782
13408
|
this.subscribedTunnels.clear();
|
|
9783
13409
|
}
|
|
9784
13410
|
isOpen() {
|
|
9785
|
-
return !!this.ws && this.ws.readyState ===
|
|
13411
|
+
return !!this.ws && this.ws.readyState === import_ws5.WebSocket.OPEN;
|
|
9786
13412
|
}
|
|
9787
13413
|
// Subscriptions
|
|
9788
13414
|
async subscribe(tunnelId, mode = SessionMode.Foreground) {
|
|
@@ -9800,7 +13426,7 @@ var init_wsStream = __esm({
|
|
|
9800
13426
|
unsubscribe(tunnelId) {
|
|
9801
13427
|
const wasSubscribed = this.subscribedTunnels.delete(tunnelId);
|
|
9802
13428
|
if (!wasSubscribed) return false;
|
|
9803
|
-
if (this.ws && this.ws.readyState ===
|
|
13429
|
+
if (this.ws && this.ws.readyState === import_ws5.WebSocket.OPEN) {
|
|
9804
13430
|
try {
|
|
9805
13431
|
const msg = { type: "unsubscribe", tunnelId };
|
|
9806
13432
|
this.ws.send(JSON.stringify(msg));
|
|
@@ -9894,8 +13520,8 @@ var init_wsStream = __esm({
|
|
|
9894
13520
|
return;
|
|
9895
13521
|
}
|
|
9896
13522
|
if (msg.type !== "tunnel_event") return;
|
|
9897
|
-
const { tunnelId, event, payload } = msg;
|
|
9898
|
-
switch (
|
|
13523
|
+
const { tunnelId, event: event2, payload } = msg;
|
|
13524
|
+
switch (event2) {
|
|
9899
13525
|
case "stats":
|
|
9900
13526
|
for (const cb of this.callbacks.stats) cb(tunnelId, payload.stats);
|
|
9901
13527
|
break;
|
|
@@ -9939,7 +13565,7 @@ var init_wsStream = __esm({
|
|
|
9939
13565
|
});
|
|
9940
13566
|
|
|
9941
13567
|
// src/daemon/daemonHealth.ts
|
|
9942
|
-
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;
|
|
9943
13569
|
var init_daemonHealth = __esm({
|
|
9944
13570
|
"src/daemon/daemonHealth.ts"() {
|
|
9945
13571
|
"use strict";
|
|
@@ -9953,7 +13579,7 @@ var init_daemonHealth = __esm({
|
|
|
9953
13579
|
HEARTBEAT_INTERVAL_MS = 5e3;
|
|
9954
13580
|
HEARTBEAT_TIMEOUT_MS = 2e3;
|
|
9955
13581
|
HEARTBEAT_FAILURE_THRESHOLD = 2;
|
|
9956
|
-
|
|
13582
|
+
sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9957
13583
|
DaemonHealth = class {
|
|
9958
13584
|
constructor(getIpc, stream) {
|
|
9959
13585
|
this.getIpc = getIpc;
|
|
@@ -10032,7 +13658,7 @@ var init_daemonHealth = __esm({
|
|
|
10032
13658
|
} catch {
|
|
10033
13659
|
}
|
|
10034
13660
|
}
|
|
10035
|
-
await
|
|
13661
|
+
await sleep3(RECONNECT_INTERVAL_MS);
|
|
10036
13662
|
if (this.lost) return;
|
|
10037
13663
|
const info = getDaemonInfo();
|
|
10038
13664
|
if (!info) {
|
|
@@ -10240,6 +13866,20 @@ var init_tunnelClient = __esm({
|
|
|
10240
13866
|
this.assertClient();
|
|
10241
13867
|
await this.ipc.restartTunnel(tunnelId);
|
|
10242
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
|
+
}
|
|
10243
13883
|
// Streaming — delegate to WsStream, guarded by daemon-lost
|
|
10244
13884
|
async attach(tunnelId, mode = SessionMode.Foreground) {
|
|
10245
13885
|
if (this.health.isLost()) return;
|
|
@@ -10379,6 +14019,7 @@ var init_tunnelClient = __esm({
|
|
|
10379
14019
|
// src/index.ts
|
|
10380
14020
|
var index_exports = {};
|
|
10381
14021
|
__export(index_exports, {
|
|
14022
|
+
DeviceAgentState: () => DeviceAgentState,
|
|
10382
14023
|
RemoteManagementUnauthorizedError: () => RemoteManagementUnauthorizedError,
|
|
10383
14024
|
TUNNEL_CONFIG_V1_KEYS: () => TUNNEL_CONFIG_V1_KEYS,
|
|
10384
14025
|
TunnelClient: () => TunnelClient,
|
|
@@ -10505,6 +14146,7 @@ init_TunnelManager();
|
|
|
10505
14146
|
init_handler();
|
|
10506
14147
|
init_tunnelClient();
|
|
10507
14148
|
init_daemonManager();
|
|
14149
|
+
init_ipcRoutes();
|
|
10508
14150
|
init_logger();
|
|
10509
14151
|
init_remoteManagement();
|
|
10510
14152
|
init_types();
|
|
@@ -10529,6 +14171,7 @@ verifyAndLoad().catch((err) => {
|
|
|
10529
14171
|
});
|
|
10530
14172
|
// Annotate the CommonJS export names for ESM import in node:
|
|
10531
14173
|
0 && (module.exports = {
|
|
14174
|
+
DeviceAgentState,
|
|
10532
14175
|
RemoteManagementUnauthorizedError,
|
|
10533
14176
|
TUNNEL_CONFIG_V1_KEYS,
|
|
10534
14177
|
TunnelClient,
|