pinggy 0.4.9 → 0.5.1
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/CLAUDE.md +112 -0
- package/README.md +216 -97
- package/dist/TunnelManager-OPUMAZFX.js +11 -0
- package/dist/TunnelTui-QZEWWH2H.js +1338 -0
- package/dist/{chunk-3RTRUYNW.js → chunk-7G6SJEEA.js} +35 -7
- package/dist/chunk-BFARGPGP.js +164 -0
- package/dist/chunk-DLNUDW6G.js +1690 -0
- package/dist/chunk-GBYF2H4H.js +77 -0
- package/dist/chunk-HUP6YWH6.js +269 -0
- package/dist/chunk-MT44NAXX.js +36 -0
- package/dist/chunk-UB26QJ4T.js +10 -0
- package/dist/chunk-VJ4VSZGX.js +2157 -0
- package/dist/chunk-Y65A4BL2.js +3407 -0
- package/dist/configStore-TSGRNOE3.js +42 -0
- package/dist/daemonChild-KXERF36J.js +24 -0
- package/dist/daemonConfig-G6S46GPJ.js +9 -0
- package/dist/index.cjs +5157 -1600
- package/dist/index.d.cts +473 -13
- package/dist/index.d.ts +473 -13
- package/dist/index.js +12 -5
- package/dist/ipcClient-LZQCCNMR.js +6 -0
- package/dist/main-KXUDW6W5.js +42 -0
- package/dist/workers/file_serve_worker.cjs +70 -21
- package/dist/workers/file_serve_worker.js +15 -9
- package/eslint.config.js +27 -0
- package/package.json +7 -3
- package/dist/chunk-YFTL44B3.js +0 -2857
- package/dist/main-4WTJG54V.js +0 -2925
|
@@ -0,0 +1,3407 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ErrorCode,
|
|
3
|
+
TunnelClient,
|
|
4
|
+
TunnelOperations,
|
|
5
|
+
buildRemoteManagementWsUrl,
|
|
6
|
+
getDaemonInfo,
|
|
7
|
+
initiateRemoteManagement,
|
|
8
|
+
isDaemonRunning,
|
|
9
|
+
isErrorResponse,
|
|
10
|
+
startDaemon,
|
|
11
|
+
startRemoteManagement,
|
|
12
|
+
stopDaemon
|
|
13
|
+
} from "./chunk-VJ4VSZGX.js";
|
|
14
|
+
import {
|
|
15
|
+
readDaemonConfig
|
|
16
|
+
} from "./chunk-MT44NAXX.js";
|
|
17
|
+
import {
|
|
18
|
+
Route,
|
|
19
|
+
SessionMode
|
|
20
|
+
} from "./chunk-BFARGPGP.js";
|
|
21
|
+
import {
|
|
22
|
+
ConfigVerb,
|
|
23
|
+
SUBCOMMANDS,
|
|
24
|
+
Subcommand,
|
|
25
|
+
deleteConfig,
|
|
26
|
+
findConfig,
|
|
27
|
+
getAutoStartConfigs,
|
|
28
|
+
listSavedConfigs,
|
|
29
|
+
printConfigDetail,
|
|
30
|
+
printConfigList,
|
|
31
|
+
saveConfig,
|
|
32
|
+
updateConfigAutoStart,
|
|
33
|
+
updateTunnelConfig,
|
|
34
|
+
validateName
|
|
35
|
+
} from "./chunk-HUP6YWH6.js";
|
|
36
|
+
import {
|
|
37
|
+
TunnelManager,
|
|
38
|
+
detachAllTunnelLoggers,
|
|
39
|
+
errorMessage,
|
|
40
|
+
getLocalAddress,
|
|
41
|
+
getRandomId,
|
|
42
|
+
getVersion,
|
|
43
|
+
isTunnelLoggingEnabled,
|
|
44
|
+
isValidPort,
|
|
45
|
+
printer_default,
|
|
46
|
+
setTunnelLoggingEnabled
|
|
47
|
+
} from "./chunk-DLNUDW6G.js";
|
|
48
|
+
import {
|
|
49
|
+
configureLogger,
|
|
50
|
+
enablePackageLogging,
|
|
51
|
+
getLogLevel,
|
|
52
|
+
logger,
|
|
53
|
+
setLogLevel
|
|
54
|
+
} from "./chunk-7G6SJEEA.js";
|
|
55
|
+
import {
|
|
56
|
+
ensurePinggyConfigDir,
|
|
57
|
+
ensurePinggyLogDir,
|
|
58
|
+
getDaemonInfoPath,
|
|
59
|
+
getDaemonLogPath,
|
|
60
|
+
getPinggyConfigDir,
|
|
61
|
+
getTunnelLogDir,
|
|
62
|
+
getTunnelLogPath
|
|
63
|
+
} from "./chunk-GBYF2H4H.js";
|
|
64
|
+
|
|
65
|
+
// src/cli/options.ts
|
|
66
|
+
var cliOptions = {
|
|
67
|
+
// SSH-like options
|
|
68
|
+
R: { type: "string", multiple: true, description: "Local port. Eg. -R0:localhost:3000 will forward tunnel connections to local port 3000." },
|
|
69
|
+
L: { type: "string", multiple: true, description: "Web Debugger address. Eg. -L4300:localhost:4300 will start web debugger on port 4300." },
|
|
70
|
+
o: { type: "string", multiple: true, description: "Options", hidden: true },
|
|
71
|
+
"server-port": { type: "string", short: "p", description: "Pinggy server port. Default: 443" },
|
|
72
|
+
v4: { type: "boolean", short: "4", description: "IPv4 only", hidden: true },
|
|
73
|
+
v6: { type: "boolean", short: "6", description: "IPv6 only", hidden: true },
|
|
74
|
+
// These options appear in the ssh command, but we ignore it in CLI
|
|
75
|
+
t: { type: "boolean", description: "hidden", hidden: true },
|
|
76
|
+
T: { type: "boolean", description: "hidden", hidden: true },
|
|
77
|
+
n: { type: "boolean", description: "hidden", hidden: true },
|
|
78
|
+
N: { type: "boolean", description: "hidden", hidden: true },
|
|
79
|
+
// Better options
|
|
80
|
+
type: { type: "string", description: "Type of the connection. Eg. --type tcp" },
|
|
81
|
+
localport: { type: "string", short: "l", description: "Takes input as [protocol:][host:]port. Eg. --localport https://localhost:8000 OR -l 3000" },
|
|
82
|
+
debugger: { type: "string", short: "d", description: "Port for web debugger. Eg. --debugger 4300 OR -d 4300" },
|
|
83
|
+
token: { type: "string", description: "Token for authentication. Eg. --token TOKEN_VALUE" },
|
|
84
|
+
force: { type: "boolean", short: "f", description: "Forcefully close existing tunnels and establish a new tunnel" },
|
|
85
|
+
follow: { type: "boolean", description: "Follow log output (stream new lines as they appear)" },
|
|
86
|
+
// Logging options (CLI overrides env)
|
|
87
|
+
loglevel: { type: "string", description: "Logging level: ERROR, INFO, DEBUG. Overrides PINGGY_LOG_LEVEL environment variable" },
|
|
88
|
+
logfile: { type: "string", description: "Path to log file. Overrides PINGGY_LOG_FILE environment variable" },
|
|
89
|
+
v: { type: "boolean", description: "Print logs to stdout for Cli. Overrides PINGGY_LOG_STDOUT environment variable" },
|
|
90
|
+
vv: { type: "boolean", description: "Enable detailed logging for the Node.js SDK and Libpinggy, including both info and debug level logs." },
|
|
91
|
+
vvv: { type: "boolean", description: "Enable all logs from Cli, SDK and internal components." },
|
|
92
|
+
"no-autoreconnect": { type: "boolean", short: "a", description: "Disable auto reconnection on failure (enabled by default)." },
|
|
93
|
+
// Save and load config (legacy file-based)
|
|
94
|
+
saveconf: { type: "string", description: "Create the configuration file based on the options provided here" },
|
|
95
|
+
conf: { type: "string", description: "Use the configuration file as base. Other options will be used to override this file" },
|
|
96
|
+
// Used by `pinggy config save` and `buildAndStartTunnel` save flow
|
|
97
|
+
save: { type: "boolean", short: "s", description: "Save the tunnel config (use with config save or -l)", hidden: true },
|
|
98
|
+
name: { type: "string", description: "Name for the tunnel config", hidden: true },
|
|
99
|
+
auto: { type: "boolean", description: "Mark tunnel config for auto-start", hidden: true },
|
|
100
|
+
// File server
|
|
101
|
+
serve: { type: "string", description: "Start a webserver to serve files from the specified path. Eg --serve /path/to/files" },
|
|
102
|
+
// Remote Control
|
|
103
|
+
"remote-management": { type: "string", description: "Enable remote management of tunnels with token. Eg. --remote-management API_KEY" },
|
|
104
|
+
manage: { type: "string", description: "Provide a server address to manage tunnels. Eg --manage dashboard.pinggy.io" },
|
|
105
|
+
noTui: { type: "boolean", description: "Disable TUI in remote management mode" },
|
|
106
|
+
notui: { type: "boolean", description: "hidden", hidden: true },
|
|
107
|
+
// Background mode (run tunnel in background via daemon)
|
|
108
|
+
b: { type: "boolean", description: "Run tunnel in background via daemon. CLI exits after tunnel starts." },
|
|
109
|
+
all: { type: "boolean", description: "Start all auto-start tunnels" },
|
|
110
|
+
// Internal daemon child marker
|
|
111
|
+
"_daemon-child": { type: "boolean", description: "Internal: daemon child process marker", hidden: true },
|
|
112
|
+
// Misc
|
|
113
|
+
version: { type: "boolean", description: "Print version" },
|
|
114
|
+
// Help
|
|
115
|
+
help: { type: "boolean", short: "h", description: "Show this help message" }
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// src/cli/help.ts
|
|
119
|
+
function printHelpMessage() {
|
|
120
|
+
console.log("\nPinggy CLI Tool - Create secure tunnels to your localhost.");
|
|
121
|
+
console.log("\nUsage:");
|
|
122
|
+
console.log(" pinggy [options] -l <port>\n");
|
|
123
|
+
console.log("Options:");
|
|
124
|
+
for (const [key, rawValue] of Object.entries(cliOptions)) {
|
|
125
|
+
const value = rawValue;
|
|
126
|
+
if (value.hidden) continue;
|
|
127
|
+
const short = value.short ? `-${value.short}, ` : " ";
|
|
128
|
+
const optType = value.type === "boolean" ? "" : "<value>";
|
|
129
|
+
console.log(` ${short}--${key.padEnd(17)} ${optType.padEnd(8)} ${value.description}`);
|
|
130
|
+
}
|
|
131
|
+
console.log("\nExtended options :");
|
|
132
|
+
console.log(" x:https Enforce HTTPS only (redirect HTTP to HTTPS)");
|
|
133
|
+
console.log(" x:noreverseproxy Disable built-in reverse-proxy header injection");
|
|
134
|
+
console.log(" x:localservertls:host Connect to local HTTPS server with SNI");
|
|
135
|
+
console.log(" x:passpreflight Pass CORS preflight requests unchanged");
|
|
136
|
+
console.log(" a:Key:Val Add header");
|
|
137
|
+
console.log(" u:Key:Val Update header");
|
|
138
|
+
console.log(" r:Key Remove header");
|
|
139
|
+
console.log(" b:user:pass Basic auth");
|
|
140
|
+
console.log(" k:BEARER Bearer token");
|
|
141
|
+
console.log(" w:192.168.1.0/24 IP whitelist (CIDR)");
|
|
142
|
+
console.log("\nExamples (User-friendly):");
|
|
143
|
+
console.log(" pinggy -l 3000 # HTTP(S) tunnel to localhost port 3000");
|
|
144
|
+
console.log(" pinggy --type tcp -l 22 # TCP tunnel for SSH (port 22)");
|
|
145
|
+
console.log(" pinggy -l 8080 -d 4300 # HTTP tunnel to port 8080 with debugger running at localhost:4300");
|
|
146
|
+
console.log(" pinggy --token mytoken -l 3000 # Authenticated tunnel");
|
|
147
|
+
console.log(" pinggy x:https x:xff -l https://localhost:8443 # HTTPS-only + XFF");
|
|
148
|
+
console.log(" pinggy w:192.168.1.0/24 -l 8080 # IP whitelist restriction");
|
|
149
|
+
console.log("\nExamples (SSH-style):");
|
|
150
|
+
console.log(" pinggy -R0:localhost:3000 # Basic HTTP tunnel");
|
|
151
|
+
console.log(" pinggy --type tcp -R0:localhost:22 # TCP tunnel for SSH");
|
|
152
|
+
console.log(" pinggy -R0:localhost:8080 -L4300:localhost:4300 # HTTP tunnel with debugger");
|
|
153
|
+
console.log(" pinggy tcp@ap.example.com -R0:localhost:22 # TCP tunnel to region");
|
|
154
|
+
console.log("\nConfig Management:");
|
|
155
|
+
console.log(" pinggy config list # List saved configs");
|
|
156
|
+
console.log(" pinggy config show my-tunnel # Show config details");
|
|
157
|
+
console.log(" pinggy config save my-tunnel -l 3000 token@pro.pinggy.io # Save config");
|
|
158
|
+
console.log(" pinggy config save my-tunnel --auto -l 3000 # Save with auto-start");
|
|
159
|
+
console.log(" pinggy config update my-tunnel -l 4000 # Update saved config");
|
|
160
|
+
console.log(" pinggy config delete my-tunnel # Delete saved config");
|
|
161
|
+
console.log(" pinggy config auto my-tunnel # Enable auto-start");
|
|
162
|
+
console.log(" pinggy config noauto my-tunnel # Disable auto-start");
|
|
163
|
+
console.log("\nStart Saved Tunnels:");
|
|
164
|
+
console.log(" pinggy start my-tunnel # Start saved tunnel");
|
|
165
|
+
console.log(" pinggy start my-tunnel -l 4000 # Start with runtime overrides");
|
|
166
|
+
console.log(" pinggy start tunnela tunnelb # Start multiple tunnels");
|
|
167
|
+
console.log(" pinggy start --all # Start all auto-start tunnels\n");
|
|
168
|
+
console.log("\nTunnel Management:");
|
|
169
|
+
console.log(" pinggy ps # List running tunnels");
|
|
170
|
+
console.log(" pinggy stop <name|id> # Stop a running tunnel");
|
|
171
|
+
console.log(" pinggy attach <name|id> # Re-attach TUI to a running tunnel");
|
|
172
|
+
console.log(" pinggy restart <name|id> # Restart a running tunnel (picks up latest log level)");
|
|
173
|
+
console.log(" pinggy logs [-f] [<name|id>] # Show (or follow) tunnel or daemon logs");
|
|
174
|
+
console.log(" pinggy log level [debug|info|error] # Get or set the log level");
|
|
175
|
+
console.log(" pinggy log path [<name|id>] # Print log file path");
|
|
176
|
+
console.log("\nBackground Mode:");
|
|
177
|
+
console.log(" pinggy -l 3000 --b # Start tunnel in background");
|
|
178
|
+
console.log(" pinggy start my-tunnel --b # Start saved tunnel in background");
|
|
179
|
+
console.log("\nDaemon Lifecycle (also: pinggy d <command>):");
|
|
180
|
+
console.log(" pinggy daemon start # Start the background daemon");
|
|
181
|
+
console.log(" pinggy daemon stop # Stop the daemon (stops all tunnels)");
|
|
182
|
+
console.log(" pinggy daemon status # Show daemon PID and uptime");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/utils/parseArgs.ts
|
|
186
|
+
import { parseArgs } from "util";
|
|
187
|
+
import * as os from "os";
|
|
188
|
+
function isAttachedReverseOrLocalFlag(arg) {
|
|
189
|
+
return /^-[RL].+/.test(arg);
|
|
190
|
+
}
|
|
191
|
+
function shouldMergeReverseOrLocalFragment(current, next) {
|
|
192
|
+
if (next.startsWith("-")) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
if (next.startsWith(".")) {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
const body = current.slice(2);
|
|
199
|
+
if (body.endsWith(":")) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (body.includes("//") && !body.includes(":")) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
function preprocessWindowsArgs(args) {
|
|
208
|
+
if (os.platform() !== "win32") {
|
|
209
|
+
return args;
|
|
210
|
+
}
|
|
211
|
+
;
|
|
212
|
+
const out = [];
|
|
213
|
+
let i = 0;
|
|
214
|
+
while (i < args.length) {
|
|
215
|
+
const arg = args[i];
|
|
216
|
+
if (isAttachedReverseOrLocalFlag(arg)) {
|
|
217
|
+
let merged = arg;
|
|
218
|
+
while (i + 1 < args.length && shouldMergeReverseOrLocalFragment(merged, args[i + 1])) {
|
|
219
|
+
merged += args[i + 1];
|
|
220
|
+
i++;
|
|
221
|
+
}
|
|
222
|
+
out.push(merged);
|
|
223
|
+
i++;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
out.push(arg);
|
|
227
|
+
i++;
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
function parseCliArgs(options, overrideArgs) {
|
|
232
|
+
const rawArgs = overrideArgs ?? process.argv.slice(2);
|
|
233
|
+
const processedArgs = preprocessWindowsArgs(rawArgs);
|
|
234
|
+
const parsed = parseArgs({
|
|
235
|
+
args: processedArgs,
|
|
236
|
+
options,
|
|
237
|
+
allowPositionals: true
|
|
238
|
+
});
|
|
239
|
+
const hasAnyArgs = parsed.positionals.length > 0 || Object.values(parsed.values).some((v) => v !== void 0 && v !== false);
|
|
240
|
+
return {
|
|
241
|
+
...parsed,
|
|
242
|
+
hasAnyArgs
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/main.ts
|
|
247
|
+
import { fileURLToPath } from "url";
|
|
248
|
+
import { argv } from "process";
|
|
249
|
+
import { realpathSync } from "fs";
|
|
250
|
+
|
|
251
|
+
// src/cli/defaults.ts
|
|
252
|
+
var defaultOptions = {
|
|
253
|
+
version: "1.0",
|
|
254
|
+
token: void 0,
|
|
255
|
+
// No default token
|
|
256
|
+
serverAddress: "a.pinggy.io",
|
|
257
|
+
forwarding: "localhost:8000",
|
|
258
|
+
webDebugger: "",
|
|
259
|
+
ipWhitelist: [],
|
|
260
|
+
basicAuth: [],
|
|
261
|
+
bearerTokenAuth: [],
|
|
262
|
+
headerModification: [],
|
|
263
|
+
force: false,
|
|
264
|
+
xForwardedFor: false,
|
|
265
|
+
httpsOnly: false,
|
|
266
|
+
originalRequestUrl: false,
|
|
267
|
+
allowPreflight: false,
|
|
268
|
+
reverseProxy: true,
|
|
269
|
+
autoReconnect: true
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// src/cli/extendedOptions.ts
|
|
273
|
+
import { isIP } from "net";
|
|
274
|
+
function parseExtendedOptions(options, config, localServerTls) {
|
|
275
|
+
if (!options) return localServerTls;
|
|
276
|
+
for (const opt of options) {
|
|
277
|
+
const [key, value] = opt.replace(/^"|"$/g, "").split(/:(.*)/).filter(Boolean);
|
|
278
|
+
switch (key) {
|
|
279
|
+
case "x":
|
|
280
|
+
switch (value) {
|
|
281
|
+
case "https":
|
|
282
|
+
case "httpsonly":
|
|
283
|
+
config.httpsOnly = true;
|
|
284
|
+
break;
|
|
285
|
+
case "passpreflight":
|
|
286
|
+
case "allowpreflight":
|
|
287
|
+
config.allowPreflight = true;
|
|
288
|
+
break;
|
|
289
|
+
case "noreverseproxy":
|
|
290
|
+
config.reverseProxy = false;
|
|
291
|
+
break;
|
|
292
|
+
case "xff":
|
|
293
|
+
config.xForwardedFor = true;
|
|
294
|
+
break;
|
|
295
|
+
case "fullurl":
|
|
296
|
+
case "fullrequesturl":
|
|
297
|
+
config.originalRequestUrl = true;
|
|
298
|
+
break;
|
|
299
|
+
default: {
|
|
300
|
+
if (value && (value.startsWith("localServerTls") || value.startsWith("localservertls"))) {
|
|
301
|
+
const parts = value.split(/:(.+)/);
|
|
302
|
+
localServerTls = parts[1] ? parts[1] : "";
|
|
303
|
+
} else {
|
|
304
|
+
printer_default.warn(`Unknown extended option "${value}"`);
|
|
305
|
+
logger.warn(`Warning: Unknown extended option "${value}"`);
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
break;
|
|
311
|
+
case "w":
|
|
312
|
+
if (value) {
|
|
313
|
+
const ips = value.split(",").map((ip) => ip.trim()).filter(Boolean);
|
|
314
|
+
const invalidIps = ips.filter((ip) => !(isValidIpV4Cidr(ip) || isValidIpV6Cidr(ip)));
|
|
315
|
+
if (invalidIps.length > 0) {
|
|
316
|
+
printer_default.warn(`Invalid IP/CIDR(s) in whitelist: ${invalidIps.join(", ")}`);
|
|
317
|
+
logger.warn(`Warning: Invalid IP/CIDR(s) in whitelist: ${invalidIps.join(", ")}`);
|
|
318
|
+
}
|
|
319
|
+
if (!(invalidIps.length > 0)) {
|
|
320
|
+
config.ipWhitelist = ips;
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
printer_default.warn(`Extended option "${opt}" for 'w' requires IP(s)`);
|
|
324
|
+
logger.warn(`Warning: Extended option "${opt}" for 'w' requires IP(s)`);
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
case "k":
|
|
328
|
+
if (!config.bearerTokenAuth) config.bearerTokenAuth = [];
|
|
329
|
+
if (value) {
|
|
330
|
+
config.bearerTokenAuth.push(value);
|
|
331
|
+
} else {
|
|
332
|
+
printer_default.warn(`Extended option "${opt}" for 'k' requires a value`);
|
|
333
|
+
logger.warn(`Warning: Extended option "${opt}" for 'k' requires a value`);
|
|
334
|
+
}
|
|
335
|
+
break;
|
|
336
|
+
case "b":
|
|
337
|
+
if (value && value.includes(":")) {
|
|
338
|
+
const [username, password] = value.split(/:(.*)/);
|
|
339
|
+
if (!config.basicAuth) config.basicAuth = [];
|
|
340
|
+
config.basicAuth.push({ username, password });
|
|
341
|
+
} else {
|
|
342
|
+
printer_default.warn(`Extended option "${opt}" for 'b' requires value in format username:password`);
|
|
343
|
+
logger.warn(`Warning: Extended option "${opt}" for 'b' requires value in format username:password`);
|
|
344
|
+
}
|
|
345
|
+
break;
|
|
346
|
+
case "a":
|
|
347
|
+
if (value && value.includes(":")) {
|
|
348
|
+
const [key2, val] = value.split(/:(.*)/);
|
|
349
|
+
if (!config.headerModification) config.headerModification = [];
|
|
350
|
+
config.headerModification.push({ type: "add", key: key2, value: [val] });
|
|
351
|
+
} else {
|
|
352
|
+
printer_default.warn(`Extended option "${opt}" for 'a' requires key:value`);
|
|
353
|
+
logger.warn(`Warning: Extended option "${opt}" for 'a' requires key:value`);
|
|
354
|
+
}
|
|
355
|
+
break;
|
|
356
|
+
case "u":
|
|
357
|
+
if (value && value.includes(":")) {
|
|
358
|
+
const [key2, val] = value.split(/:(.*)/);
|
|
359
|
+
if (!config.headerModification) config.headerModification = [];
|
|
360
|
+
config.headerModification.push({ type: "update", key: key2, value: [val] });
|
|
361
|
+
} else {
|
|
362
|
+
printer_default.warn(`Extended option "${opt}" for 'u' requires key:value`);
|
|
363
|
+
logger.warn(`Warning: Extended option "${opt}" for 'u' requires key:value`);
|
|
364
|
+
}
|
|
365
|
+
break;
|
|
366
|
+
case "r":
|
|
367
|
+
if (value) {
|
|
368
|
+
if (!config.headerModification) config.headerModification = [];
|
|
369
|
+
config.headerModification.push({ type: "remove", key: value, value: [] });
|
|
370
|
+
} else {
|
|
371
|
+
printer_default.warn(`Extended option "${opt}" for 'r' requires a key`);
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
default:
|
|
375
|
+
printer_default.warn(`Unknown extended option "${key}"`);
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return localServerTls;
|
|
380
|
+
}
|
|
381
|
+
function isValidIpV4Cidr(input) {
|
|
382
|
+
if (input.includes("/")) {
|
|
383
|
+
const [ip, mask] = input.split("/");
|
|
384
|
+
if (!ip || !mask) return false;
|
|
385
|
+
const isIp4 = isIP(ip) === 4;
|
|
386
|
+
const maskNum = parseInt(mask, 10);
|
|
387
|
+
const isMaskValid = !isNaN(maskNum) && maskNum >= 0 && maskNum <= 32;
|
|
388
|
+
return isIp4 && isMaskValid;
|
|
389
|
+
}
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
function isValidIpV6Cidr(input) {
|
|
393
|
+
if (input.includes("/")) {
|
|
394
|
+
const [rawIp, mask] = input.split("/");
|
|
395
|
+
if (!rawIp || !mask) return false;
|
|
396
|
+
const ip = rawIp.split("%")[0].replace(/^\[|\]$/g, "");
|
|
397
|
+
const isIp6 = isIP(ip) === 6;
|
|
398
|
+
const maskNum = parseInt(mask, 10);
|
|
399
|
+
const isMaskValid = !isNaN(maskNum) && maskNum >= 0 && maskNum <= 128;
|
|
400
|
+
return isIp6 && isMaskValid;
|
|
401
|
+
}
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/cli/buildConfig.ts
|
|
406
|
+
import { TunnelType } from "@pinggy/pinggy";
|
|
407
|
+
import fs from "fs";
|
|
408
|
+
import path from "path";
|
|
409
|
+
import { isIP as isIP2 } from "net";
|
|
410
|
+
var domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
|
411
|
+
function removeIPv6Brackets(ip) {
|
|
412
|
+
if (ip.startsWith("[") && ip.endsWith("]")) {
|
|
413
|
+
return ip.slice(1, -1);
|
|
414
|
+
}
|
|
415
|
+
return ip;
|
|
416
|
+
}
|
|
417
|
+
function isValidServerAddress(host) {
|
|
418
|
+
const normalized = removeIPv6Brackets(host.trim());
|
|
419
|
+
if (!normalized) {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
return domainRegex.test(normalized) || isIP2(normalized) !== 0;
|
|
423
|
+
}
|
|
424
|
+
var KEYWORDS = /* @__PURE__ */ new Set([
|
|
425
|
+
TunnelType.Http,
|
|
426
|
+
TunnelType.Tcp,
|
|
427
|
+
TunnelType.Tls,
|
|
428
|
+
TunnelType.Udp,
|
|
429
|
+
TunnelType.TlsTcp,
|
|
430
|
+
"force",
|
|
431
|
+
"qr"
|
|
432
|
+
]);
|
|
433
|
+
function isKeyword(str) {
|
|
434
|
+
return KEYWORDS.has(str.toLowerCase());
|
|
435
|
+
}
|
|
436
|
+
function parseUserAndDomain(str) {
|
|
437
|
+
let token;
|
|
438
|
+
let type;
|
|
439
|
+
let server;
|
|
440
|
+
let qrCode;
|
|
441
|
+
let forceFlag;
|
|
442
|
+
if (!str) {
|
|
443
|
+
return { token, type, server, qrCode, forceFlag };
|
|
444
|
+
}
|
|
445
|
+
if (str.includes("@")) {
|
|
446
|
+
const [user, domain] = str.split("@", 2);
|
|
447
|
+
if (isValidServerAddress(domain)) {
|
|
448
|
+
let processKeyword2 = function(keyword) {
|
|
449
|
+
if ([TunnelType.Http, TunnelType.Tcp, TunnelType.Tls, TunnelType.Udp, TunnelType.TlsTcp].includes(keyword)) {
|
|
450
|
+
type = keyword;
|
|
451
|
+
} else if (keyword === "force") {
|
|
452
|
+
forceFlag = true;
|
|
453
|
+
} else if (keyword === "qr") {
|
|
454
|
+
qrCode = true;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
var processKeyword = processKeyword2;
|
|
458
|
+
server = domain;
|
|
459
|
+
const parts = user.split("+");
|
|
460
|
+
if (parts.length === 0) {
|
|
461
|
+
return { token, type, server, qrCode, forceFlag };
|
|
462
|
+
}
|
|
463
|
+
const firstPart = parts[0];
|
|
464
|
+
if (!isKeyword(firstPart)) {
|
|
465
|
+
token = firstPart;
|
|
466
|
+
for (let i = 1; i < parts.length; i++) {
|
|
467
|
+
const part = parts[i].toLowerCase();
|
|
468
|
+
if (!isKeyword(part)) {
|
|
469
|
+
throw new Error(`Invalid user format: unexpected token '${part}' when keywords are expected.`);
|
|
470
|
+
}
|
|
471
|
+
processKeyword2(part);
|
|
472
|
+
}
|
|
473
|
+
} else {
|
|
474
|
+
for (const part of parts) {
|
|
475
|
+
const lowerPart = part.toLowerCase();
|
|
476
|
+
if (!isKeyword(lowerPart)) {
|
|
477
|
+
throw new Error(`Invalid user format: unexpected token '${lowerPart}' when keywords are expected.`);
|
|
478
|
+
}
|
|
479
|
+
processKeyword2(lowerPart);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} else if (isValidServerAddress(str)) {
|
|
484
|
+
server = str;
|
|
485
|
+
}
|
|
486
|
+
return { token, type, server, qrCode, forceFlag };
|
|
487
|
+
}
|
|
488
|
+
function parseUsers(positionalArgs, explicitToken) {
|
|
489
|
+
let token;
|
|
490
|
+
let server;
|
|
491
|
+
let type;
|
|
492
|
+
let forceFlag = false;
|
|
493
|
+
let qrCode = false;
|
|
494
|
+
let remaining = [...positionalArgs];
|
|
495
|
+
if (typeof explicitToken === "string") {
|
|
496
|
+
const parsed = parseUserAndDomain(explicitToken);
|
|
497
|
+
if (parsed.server) {
|
|
498
|
+
server = parsed.server;
|
|
499
|
+
}
|
|
500
|
+
if (parsed.type) {
|
|
501
|
+
type = parsed.type;
|
|
502
|
+
}
|
|
503
|
+
if (parsed.token) {
|
|
504
|
+
token = parsed.token;
|
|
505
|
+
}
|
|
506
|
+
if (parsed.forceFlag) {
|
|
507
|
+
forceFlag = true;
|
|
508
|
+
}
|
|
509
|
+
if (parsed.qrCode) {
|
|
510
|
+
qrCode = true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (remaining.length > 0) {
|
|
514
|
+
const first = remaining[0];
|
|
515
|
+
const parsed = parseUserAndDomain(first);
|
|
516
|
+
if (parsed.server) {
|
|
517
|
+
server = parsed.server;
|
|
518
|
+
if (parsed.type) {
|
|
519
|
+
type = parsed.type;
|
|
520
|
+
}
|
|
521
|
+
if (parsed.token) {
|
|
522
|
+
token = parsed.token;
|
|
523
|
+
}
|
|
524
|
+
if (parsed.forceFlag) {
|
|
525
|
+
forceFlag = true;
|
|
526
|
+
}
|
|
527
|
+
if (parsed.qrCode) {
|
|
528
|
+
qrCode = true;
|
|
529
|
+
}
|
|
530
|
+
remaining = remaining.slice(1);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return { token, server, type, forceFlag, qrCode, remaining };
|
|
534
|
+
}
|
|
535
|
+
function parseType(finalConfig, values, inferredType) {
|
|
536
|
+
const t = inferredType || values.type;
|
|
537
|
+
if (t === TunnelType.Http || t === TunnelType.Tcp || t === TunnelType.Tls || t === TunnelType.Udp || t === TunnelType.TlsTcp) {
|
|
538
|
+
return t;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function parseLocalPort(finalConfig, values) {
|
|
542
|
+
if (typeof values.localport !== "string") {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
let lp = values.localport.trim();
|
|
546
|
+
let isHttps = false;
|
|
547
|
+
if (lp.startsWith("https://")) {
|
|
548
|
+
isHttps = true;
|
|
549
|
+
lp = lp.replace(/^https:\/\//, "");
|
|
550
|
+
} else if (lp.startsWith("http://")) {
|
|
551
|
+
lp = lp.replace(/^http:\/\//, "");
|
|
552
|
+
}
|
|
553
|
+
const parts = lp.split(":");
|
|
554
|
+
if (parts.length === 1) {
|
|
555
|
+
const port = parseInt(parts[0], 10);
|
|
556
|
+
if (!Number.isNaN(port) && isValidPort(port)) {
|
|
557
|
+
finalConfig.forwarding = `localhost:${port}`;
|
|
558
|
+
} else {
|
|
559
|
+
return new Error("Invalid local port");
|
|
560
|
+
}
|
|
561
|
+
} else if (parts.length === 2) {
|
|
562
|
+
const host = parts[0] || "localhost";
|
|
563
|
+
const port = parseInt(parts[1], 10);
|
|
564
|
+
if (!Number.isNaN(port) && isValidPort(port)) {
|
|
565
|
+
finalConfig.forwarding = `${host}:${port}`;
|
|
566
|
+
} else {
|
|
567
|
+
return new Error("Invalid local port. Please use -h option for help.");
|
|
568
|
+
}
|
|
569
|
+
} else {
|
|
570
|
+
return new Error("Invalid --localport format. Please use -h option for help.");
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
function isValidHostAddress(host) {
|
|
575
|
+
const normalized = removeIPv6Brackets(host.trim());
|
|
576
|
+
if (normalized.length === 0) {
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
return normalized === "localhost" || isIP2(normalized) !== 0;
|
|
580
|
+
}
|
|
581
|
+
function ipv6SafeSplitColon(s) {
|
|
582
|
+
const result = [];
|
|
583
|
+
let buf = "";
|
|
584
|
+
const stack = [];
|
|
585
|
+
for (let i = 0; i < s.length; i++) {
|
|
586
|
+
const c = s[i];
|
|
587
|
+
if (c === "[") {
|
|
588
|
+
stack.push(c);
|
|
589
|
+
} else if (c === "]" && stack.length > 0) {
|
|
590
|
+
stack.pop();
|
|
591
|
+
}
|
|
592
|
+
if (c === ":" && stack.length === 0) {
|
|
593
|
+
result.push(buf);
|
|
594
|
+
buf = "";
|
|
595
|
+
} else {
|
|
596
|
+
buf += c;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
result.push(buf);
|
|
600
|
+
return result;
|
|
601
|
+
}
|
|
602
|
+
var VALID_PROTOCOLS = [TunnelType.Http, TunnelType.Tcp, TunnelType.Udp, TunnelType.Tls, TunnelType.TlsTcp];
|
|
603
|
+
function parseDefaultForwarding(forwarding) {
|
|
604
|
+
const parts = ipv6SafeSplitColon(forwarding);
|
|
605
|
+
if (parts.length === 3) {
|
|
606
|
+
const remotePort = parseInt(parts[0], 10);
|
|
607
|
+
const localDomain = removeIPv6Brackets(parts[1] || "localhost");
|
|
608
|
+
const localPort = parseInt(parts[2], 10);
|
|
609
|
+
return { remotePort, localDomain, localPort };
|
|
610
|
+
}
|
|
611
|
+
if (parts.length === 4) {
|
|
612
|
+
const remoteDomain = removeIPv6Brackets(parts[0]);
|
|
613
|
+
const remotePort = parseInt(parts[1], 10);
|
|
614
|
+
const localDomain = removeIPv6Brackets(parts[2] || "localhost");
|
|
615
|
+
const localPort = parseInt(parts[3], 10);
|
|
616
|
+
return { remoteDomain, remotePort, localDomain, localPort };
|
|
617
|
+
}
|
|
618
|
+
return new Error("forwarding address incorrect");
|
|
619
|
+
}
|
|
620
|
+
function parseAdditionalForwarding(forwarding) {
|
|
621
|
+
const toPort = (v) => {
|
|
622
|
+
if (!v) {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
const n = parseInt(v, 10);
|
|
626
|
+
return Number.isNaN(n) ? null : n;
|
|
627
|
+
};
|
|
628
|
+
const parsed = ipv6SafeSplitColon(forwarding);
|
|
629
|
+
if (parsed.length !== 4) {
|
|
630
|
+
return new Error(
|
|
631
|
+
"forwarding must be in format: [schema//]hostname[/port][@forwardingId]:<placeholder>:<forwardingAddress>:<forwardingPort>"
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
const firstPart = parsed[0];
|
|
635
|
+
const [hostPart] = firstPart.split("@");
|
|
636
|
+
let protocol = TunnelType.Http;
|
|
637
|
+
let remoteDomainRaw;
|
|
638
|
+
let remotePort = 0;
|
|
639
|
+
if (hostPart.includes("//")) {
|
|
640
|
+
const [schema, rest] = hostPart.split("//");
|
|
641
|
+
if (!schema || !VALID_PROTOCOLS.includes(schema)) {
|
|
642
|
+
return new Error(`invalid protocol: ${schema}`);
|
|
643
|
+
}
|
|
644
|
+
protocol = schema;
|
|
645
|
+
const domainAndPort = rest.split("/");
|
|
646
|
+
if (domainAndPort.length > 2) {
|
|
647
|
+
return new Error("invalid forwarding address format");
|
|
648
|
+
}
|
|
649
|
+
remoteDomainRaw = domainAndPort[0];
|
|
650
|
+
if (!remoteDomainRaw || !isValidServerAddress(remoteDomainRaw)) {
|
|
651
|
+
return new Error("invalid remote domain");
|
|
652
|
+
}
|
|
653
|
+
const parsedRemotePort = toPort(domainAndPort[1]);
|
|
654
|
+
if (protocol === "http") {
|
|
655
|
+
remotePort = 0;
|
|
656
|
+
} else {
|
|
657
|
+
if (parsedRemotePort === null || !isValidPort(parsedRemotePort)) {
|
|
658
|
+
return new Error(
|
|
659
|
+
`${protocol} forwarding requires port in format ${protocol}//domain/remotePort`
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
remotePort = parsedRemotePort;
|
|
663
|
+
}
|
|
664
|
+
} else {
|
|
665
|
+
remoteDomainRaw = hostPart;
|
|
666
|
+
if (!isValidServerAddress(remoteDomainRaw)) {
|
|
667
|
+
return new Error("invalid remote domain");
|
|
668
|
+
}
|
|
669
|
+
protocol = TunnelType.Http;
|
|
670
|
+
remotePort = 0;
|
|
671
|
+
}
|
|
672
|
+
const localDomain = removeIPv6Brackets(parsed[2] || "localhost");
|
|
673
|
+
const localPort = toPort(parsed[3]);
|
|
674
|
+
if (localPort === null || !isValidPort(localPort)) {
|
|
675
|
+
return new Error("forwarding address incorrect: invalid local port");
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
type: protocol,
|
|
679
|
+
listenAddress: `${remoteDomainRaw}:${remotePort}`,
|
|
680
|
+
address: `${localDomain}:${localPort}`
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
function parseReverseTunnelAddr(finalConfig, values, primaryType) {
|
|
684
|
+
const reverseTunnel = values.R;
|
|
685
|
+
let forwardingData = [];
|
|
686
|
+
if ((!Array.isArray(reverseTunnel) || reverseTunnel.length === 0) && !values.localport && !finalConfig.forwarding) {
|
|
687
|
+
return new Error("local port not specified. Please use '-h' option for help.");
|
|
688
|
+
}
|
|
689
|
+
if (!Array.isArray(reverseTunnel) || reverseTunnel.length === 0) {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
for (const forwarding of reverseTunnel) {
|
|
693
|
+
const slicedForwarding = ipv6SafeSplitColon(forwarding);
|
|
694
|
+
if (slicedForwarding.length === 3) {
|
|
695
|
+
const parsed = parseDefaultForwarding(forwarding);
|
|
696
|
+
if (parsed instanceof Error) return parsed;
|
|
697
|
+
forwardingData.push({
|
|
698
|
+
address: `${parsed.localDomain}:${parsed.localPort}`,
|
|
699
|
+
type: primaryType || TunnelType.Http
|
|
700
|
+
});
|
|
701
|
+
} else if (slicedForwarding.length === 4) {
|
|
702
|
+
const parsed = parseAdditionalForwarding(forwarding);
|
|
703
|
+
if (parsed instanceof Error) {
|
|
704
|
+
return parsed;
|
|
705
|
+
}
|
|
706
|
+
forwardingData.push(parsed);
|
|
707
|
+
} else {
|
|
708
|
+
return new Error(
|
|
709
|
+
"Incorrect command line arguments: reverse tunnel address incorrect. Please use '-h' option for help."
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
finalConfig.forwarding = forwardingData;
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
function parseLocalTunnelAddr(finalConfig, values) {
|
|
717
|
+
if (!Array.isArray(values.L) || values.L.length === 0) {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
const firstL = values.L[0];
|
|
721
|
+
const parts = ipv6SafeSplitColon(firstL);
|
|
722
|
+
let debuggerHost = "localhost";
|
|
723
|
+
let lp;
|
|
724
|
+
if (parts.length === 3) {
|
|
725
|
+
lp = parseInt(parts[0], 10);
|
|
726
|
+
} else if (parts.length === 4) {
|
|
727
|
+
debuggerHost = removeIPv6Brackets(parts[0]);
|
|
728
|
+
lp = parseInt(parts[1], 10);
|
|
729
|
+
} else {
|
|
730
|
+
return new Error("Incorrect command line arguments: web debugger address incorrect. Please use '-h' option for help.");
|
|
731
|
+
}
|
|
732
|
+
if (!isValidHostAddress(debuggerHost)) {
|
|
733
|
+
return new Error(`Invalid debugger host ${debuggerHost}. Please use localhost, IPv4, or IPv6 address.`);
|
|
734
|
+
}
|
|
735
|
+
if (!Number.isNaN(lp) && isValidPort(lp)) {
|
|
736
|
+
finalConfig.webDebugger = `${debuggerHost}:${lp}`;
|
|
737
|
+
} else {
|
|
738
|
+
return new Error(`Invalid debugger port ${lp}`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
function parseDebugger(finalConfig, values) {
|
|
742
|
+
let dbg = values.debugger;
|
|
743
|
+
if (typeof dbg !== "string") {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
dbg = dbg.startsWith(":") ? dbg.slice(1) : dbg;
|
|
747
|
+
const d = parseInt(dbg, 10);
|
|
748
|
+
if (!Number.isNaN(d) && isValidPort(d)) {
|
|
749
|
+
finalConfig.webDebugger = `localhost:${d}`;
|
|
750
|
+
} else {
|
|
751
|
+
logger.error("Invalid debugger port:", dbg);
|
|
752
|
+
return new Error(`Invalid debugger port ${dbg}. Please use '-h' option for help.`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
function parseToken(finalConfig, explicitToken) {
|
|
756
|
+
if (typeof explicitToken === "string" && explicitToken) {
|
|
757
|
+
finalConfig.token = explicitToken;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function parseArgs2(finalConfig, remainingPositionals) {
|
|
761
|
+
let localserverTls = "";
|
|
762
|
+
localserverTls = parseExtendedOptions(remainingPositionals, finalConfig, localserverTls);
|
|
763
|
+
if (localserverTls.length > 0 && finalConfig.forwarding) {
|
|
764
|
+
if (typeof finalConfig.forwarding[0] === "object" && "address" in finalConfig.forwarding[0]) {
|
|
765
|
+
finalConfig.forwarding[0].address = `https://${finalConfig.forwarding[0].address}`;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
function storeJson(config, saveconf) {
|
|
770
|
+
if (saveconf) {
|
|
771
|
+
const path5 = saveconf;
|
|
772
|
+
try {
|
|
773
|
+
fs.writeFileSync(path5, JSON.stringify(config, null, 2), { encoding: "utf-8", flag: "w" });
|
|
774
|
+
logger.info(`Configuration saved to ${path5}`);
|
|
775
|
+
} catch (err) {
|
|
776
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
777
|
+
logger.error("Error loading configuration:", msg);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
function loadJsonConfig(config) {
|
|
782
|
+
const configpath = config["conf"];
|
|
783
|
+
if (typeof configpath === "string" && configpath.trim().length > 0) {
|
|
784
|
+
const filepath = path.resolve(configpath);
|
|
785
|
+
try {
|
|
786
|
+
const data = fs.readFileSync(filepath, { encoding: "utf-8" });
|
|
787
|
+
const json = JSON.parse(data);
|
|
788
|
+
return json;
|
|
789
|
+
} catch (err) {
|
|
790
|
+
logger.error("Error loading configuration:", err);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
function isSaveConfOption(values) {
|
|
796
|
+
const saveconf = values["saveconf"];
|
|
797
|
+
if (typeof saveconf === "string" && saveconf.trim().length > 0) {
|
|
798
|
+
return saveconf;
|
|
799
|
+
}
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
function parseServe(finalConfig, values) {
|
|
803
|
+
const sv = values.serve;
|
|
804
|
+
if (typeof sv !== "string" || sv.trim().length === 0) {
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
finalConfig.optional.serve = sv;
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
function parseAutoReconnect(finalConfig, values) {
|
|
811
|
+
if (values["no-autoreconnect"]) {
|
|
812
|
+
finalConfig.autoReconnect = false;
|
|
813
|
+
}
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
function hasRemoteManagement(values) {
|
|
817
|
+
const token = values["remote-management"];
|
|
818
|
+
return typeof token === "string" && token.trim().length > 0;
|
|
819
|
+
}
|
|
820
|
+
function buildFinalConfig(values, positionals, baseConfig) {
|
|
821
|
+
let token;
|
|
822
|
+
let server;
|
|
823
|
+
let type;
|
|
824
|
+
let forceFlag = false;
|
|
825
|
+
let qrCode = false;
|
|
826
|
+
let finalConfig = new Object();
|
|
827
|
+
let saveconf = isSaveConfOption(values);
|
|
828
|
+
const configFromFile = baseConfig || loadJsonConfig(values);
|
|
829
|
+
const userParse = parseUsers(positionals, values.token);
|
|
830
|
+
token = userParse.token;
|
|
831
|
+
server = userParse.server;
|
|
832
|
+
type = userParse.type;
|
|
833
|
+
forceFlag = userParse.forceFlag;
|
|
834
|
+
qrCode = userParse.qrCode;
|
|
835
|
+
const remainingPositionals = userParse.remaining;
|
|
836
|
+
const initialTunnel = type || values.type;
|
|
837
|
+
finalConfig = {
|
|
838
|
+
...defaultOptions,
|
|
839
|
+
...configFromFile || {},
|
|
840
|
+
// Apply loaded config on top of defaults
|
|
841
|
+
configId: configFromFile?.configId || getRandomId(),
|
|
842
|
+
token: token || (configFromFile?.token || (typeof values.token === "string" ? values.token : "")),
|
|
843
|
+
serverAddress: server ? removeIPv6Brackets(server) : configFromFile?.serverAddress || defaultOptions.serverAddress,
|
|
844
|
+
isQRCode: qrCode || (configFromFile?.isQRCode || false),
|
|
845
|
+
autoReconnect: configFromFile?.autoReconnect ? configFromFile.autoReconnect : defaultOptions.autoReconnect,
|
|
846
|
+
optional: {
|
|
847
|
+
serve: configFromFile?.optional?.serve || void 0,
|
|
848
|
+
noTui: values.noTui || values.notui || hasRemoteManagement(values) || (configFromFile?.optional?.noTui || false)
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
type = parseType(finalConfig, values, type);
|
|
852
|
+
parseToken(finalConfig, token || values.token);
|
|
853
|
+
const dbgErr = parseDebugger(finalConfig, values);
|
|
854
|
+
if (dbgErr instanceof Error) {
|
|
855
|
+
throw dbgErr;
|
|
856
|
+
}
|
|
857
|
+
const lpErr = parseLocalPort(finalConfig, values);
|
|
858
|
+
if (lpErr instanceof Error) {
|
|
859
|
+
throw lpErr;
|
|
860
|
+
}
|
|
861
|
+
const rErr = parseReverseTunnelAddr(finalConfig, values, type);
|
|
862
|
+
if (rErr instanceof Error) {
|
|
863
|
+
throw rErr;
|
|
864
|
+
}
|
|
865
|
+
const lErr = parseLocalTunnelAddr(finalConfig, values);
|
|
866
|
+
if (lErr instanceof Error) {
|
|
867
|
+
throw lErr;
|
|
868
|
+
}
|
|
869
|
+
const serveErr = parseServe(finalConfig, values);
|
|
870
|
+
if (serveErr instanceof Error) {
|
|
871
|
+
throw serveErr;
|
|
872
|
+
}
|
|
873
|
+
const autoReconnectErr = parseAutoReconnect(finalConfig, values);
|
|
874
|
+
if (autoReconnectErr instanceof Error) {
|
|
875
|
+
throw autoReconnectErr;
|
|
876
|
+
}
|
|
877
|
+
if (forceFlag || values.force) {
|
|
878
|
+
finalConfig.force = true;
|
|
879
|
+
}
|
|
880
|
+
parseArgs2(finalConfig, remainingPositionals);
|
|
881
|
+
storeJson(finalConfig, saveconf);
|
|
882
|
+
return finalConfig;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// src/utils/getFreePort.ts
|
|
886
|
+
import net from "net";
|
|
887
|
+
function getFreePort(webDebugger) {
|
|
888
|
+
return new Promise((resolve, reject) => {
|
|
889
|
+
const tryPort = (portToTry) => {
|
|
890
|
+
const server = net.createServer();
|
|
891
|
+
server.unref();
|
|
892
|
+
server.on("error", (err) => {
|
|
893
|
+
if (portToTry !== 0) {
|
|
894
|
+
tryPort(0);
|
|
895
|
+
} else {
|
|
896
|
+
reject(err);
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
server.listen(portToTry, () => {
|
|
900
|
+
const address = server.address();
|
|
901
|
+
const port = address ? address.port : 0;
|
|
902
|
+
server.close(() => resolve(port));
|
|
903
|
+
});
|
|
904
|
+
};
|
|
905
|
+
let providedPort = 0;
|
|
906
|
+
if (webDebugger && webDebugger.includes(":")) {
|
|
907
|
+
const portPart = webDebugger.split(":")[1];
|
|
908
|
+
const parsed = parseInt(portPart, 10);
|
|
909
|
+
if (!isNaN(parsed) && parsed > 0 && parsed < 65536) {
|
|
910
|
+
providedPort = parsed;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
tryPort(providedPort);
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// src/cli/startCli.ts
|
|
918
|
+
import pico from "picocolors";
|
|
919
|
+
|
|
920
|
+
// src/utils/daemonLostMessage.ts
|
|
921
|
+
function daemonLostMessage(reason, detail) {
|
|
922
|
+
switch (reason) {
|
|
923
|
+
case "dead":
|
|
924
|
+
return "Daemon process is no longer running. Tunnel stopped.";
|
|
925
|
+
case "respawned":
|
|
926
|
+
return `Daemon was restarted${detail ? ` (${detail})` : ""}. The previous session is gone.`;
|
|
927
|
+
case "hung":
|
|
928
|
+
return "Daemon stopped responding after retries. Tunnel stopped.";
|
|
929
|
+
case "heartbeat":
|
|
930
|
+
return "Daemon stopped responding to health checks. Tunnel stopped.";
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// src/cli/startCli.ts
|
|
935
|
+
var EXIT_DAEMON_LOST = 3;
|
|
936
|
+
function wireDaemonLost(client, handlers) {
|
|
937
|
+
client.onDaemonReconnecting(handlers.onReconnecting);
|
|
938
|
+
client.onDaemonReconnected(handlers.onReconnected);
|
|
939
|
+
client.onDaemonLost((reason, detail) => {
|
|
940
|
+
handlers.onLost(reason, detail);
|
|
941
|
+
printer_default.error(daemonLostMessage(reason, detail));
|
|
942
|
+
setImmediate(() => process.exit(EXIT_DAEMON_LOST));
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
function installShutdownHandlers(handler) {
|
|
946
|
+
let fired = false;
|
|
947
|
+
const wrapped = () => {
|
|
948
|
+
if (fired) return;
|
|
949
|
+
fired = true;
|
|
950
|
+
void handler();
|
|
951
|
+
};
|
|
952
|
+
process.on("SIGINT", wrapped);
|
|
953
|
+
process.on("SIGTERM", wrapped);
|
|
954
|
+
process.on("SIGHUP", wrapped);
|
|
955
|
+
}
|
|
956
|
+
async function initTunnelClient() {
|
|
957
|
+
const client = new TunnelClient();
|
|
958
|
+
printer_default.startSpinner("Initializing...");
|
|
959
|
+
try {
|
|
960
|
+
await client.ensureDaemon();
|
|
961
|
+
} catch (err) {
|
|
962
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
963
|
+
printer_default.stopSpinnerFail(`Failed to start daemon: ${msg}`);
|
|
964
|
+
process.exit(1);
|
|
965
|
+
}
|
|
966
|
+
printer_default.stopSpinnerSuccess("Initialized");
|
|
967
|
+
return client;
|
|
968
|
+
}
|
|
969
|
+
function printRemoteUrls(urls) {
|
|
970
|
+
for (const url of urls || []) {
|
|
971
|
+
printer_default.print(" " + pico.magentaBright(url));
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
async function printAlreadyRunning(client, configId, label) {
|
|
975
|
+
const prefix = label ? `"${label}" ` : "";
|
|
976
|
+
const list = await client.handleListV2();
|
|
977
|
+
if (isErrorResponse(list)) {
|
|
978
|
+
printer_default.warn(`${prefix}is already running, but could not fetch its state: ${list.message}`);
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
const tunnel = configId ? list.find((t) => t.tunnelconfig?.configId === configId) : void 0;
|
|
982
|
+
if (!tunnel) {
|
|
983
|
+
printer_default.warn(`${prefix}is already running, but it is no longer in the tunnel list.`);
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
const shortId = tunnel.tunnelid.slice(0, 8);
|
|
987
|
+
const name = tunnel.tunnelconfig?.name || label || shortId;
|
|
988
|
+
printer_default.info(pico.cyanBright(`Tunnel "${name}" is already running.`));
|
|
989
|
+
printer_default.print(pico.gray("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
990
|
+
printer_default.print(` ID: ${pico.bold(shortId)}`);
|
|
991
|
+
printer_default.print(` Status: ${tunnel.status?.state || "unknown"}`);
|
|
992
|
+
if (tunnel.remoteurls?.length) {
|
|
993
|
+
printer_default.print(" URLs:");
|
|
994
|
+
printRemoteUrls(tunnel.remoteurls);
|
|
995
|
+
}
|
|
996
|
+
printer_default.print(pico.gray("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
997
|
+
printer_default.print(pico.gray(`Use 'pinggy attach ${name}' to view live output, or 'pinggy stop ${name}' to stop it.`));
|
|
998
|
+
return tunnel;
|
|
999
|
+
}
|
|
1000
|
+
async function startTunnel(client, config, opts) {
|
|
1001
|
+
const result = await client.handleStartV2(config, false, opts.mode);
|
|
1002
|
+
const prefix = opts.label ? `[${opts.label}] ` : "";
|
|
1003
|
+
const fail = (reason) => {
|
|
1004
|
+
const msg = `${prefix}Failed to start tunnel: ${reason}`;
|
|
1005
|
+
if (opts.onError === "fatal") {
|
|
1006
|
+
printer_default.error(msg);
|
|
1007
|
+
client.close();
|
|
1008
|
+
process.exit(1);
|
|
1009
|
+
}
|
|
1010
|
+
printer_default.error(msg);
|
|
1011
|
+
return null;
|
|
1012
|
+
};
|
|
1013
|
+
if (isErrorResponse(result)) {
|
|
1014
|
+
if (result.code === ErrorCode.TunnelAlreadyRunningError) {
|
|
1015
|
+
await printAlreadyRunning(client, config.configId, opts.label);
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
return fail(result.message);
|
|
1019
|
+
}
|
|
1020
|
+
const lastError = result.status?.lastError;
|
|
1021
|
+
if (lastError?.isFatal) {
|
|
1022
|
+
return fail(lastError.message);
|
|
1023
|
+
}
|
|
1024
|
+
return result;
|
|
1025
|
+
}
|
|
1026
|
+
async function waitForShutdownAndStopAll(client, ids) {
|
|
1027
|
+
client.onDisconnect((id, error) => {
|
|
1028
|
+
printer_default.warn(`[${id.slice(0, 8)}] Disconnected: ${error}`);
|
|
1029
|
+
});
|
|
1030
|
+
client.onReconnected((id, newUrls) => {
|
|
1031
|
+
printer_default.success(`[${id.slice(0, 8)}] Reconnected: ${newUrls.join(", ")}`);
|
|
1032
|
+
});
|
|
1033
|
+
client.onReconnecting((id, retryCnt) => {
|
|
1034
|
+
printer_default.print(pico.gray(`[${id.slice(0, 8)}] Reconnecting (attempt #${retryCnt})...`));
|
|
1035
|
+
});
|
|
1036
|
+
client.onReconnectionFailed((id, retryCnt) => {
|
|
1037
|
+
printer_default.error(`[${id.slice(0, 8)}] Reconnection failed after ${retryCnt} attempts`);
|
|
1038
|
+
});
|
|
1039
|
+
wireDaemonLost(client, {
|
|
1040
|
+
onReconnecting: (attempt, max) => printer_default.warn(`Daemon connection dropped \u2014 reconnecting (${attempt}/${max})...`),
|
|
1041
|
+
onReconnected: () => printer_default.success("Daemon reconnected."),
|
|
1042
|
+
onLost: () => {
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
await new Promise((resolve) => {
|
|
1046
|
+
installShutdownHandlers(async () => {
|
|
1047
|
+
printer_default.print("\nStopping all tunnels...");
|
|
1048
|
+
if (!client.isDaemonLost()) {
|
|
1049
|
+
for (const id of ids) {
|
|
1050
|
+
try {
|
|
1051
|
+
await client.handleStop(id);
|
|
1052
|
+
} catch {
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
client.close();
|
|
1057
|
+
resolve();
|
|
1058
|
+
});
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
function findTunnel(tunnels, nameOrId) {
|
|
1062
|
+
let byShortId;
|
|
1063
|
+
let byName;
|
|
1064
|
+
let byConfigId;
|
|
1065
|
+
for (const t of tunnels) {
|
|
1066
|
+
if (t.tunnelid === nameOrId) return t;
|
|
1067
|
+
if (!byShortId && t.tunnelid.startsWith(nameOrId)) byShortId = t;
|
|
1068
|
+
if (!byName && t.tunnelconfig?.name === nameOrId) byName = t;
|
|
1069
|
+
if (!byConfigId && t.tunnelconfig?.configId === nameOrId) byConfigId = t;
|
|
1070
|
+
}
|
|
1071
|
+
return byShortId ?? byName ?? byConfigId ?? null;
|
|
1072
|
+
}
|
|
1073
|
+
async function connectTui(opts) {
|
|
1074
|
+
const { client, tunnelId, urls, greet, tunnelConfig, onExit, noTui } = opts;
|
|
1075
|
+
const exitMessage = opts.exitMessage || "Stopping tunnel...";
|
|
1076
|
+
if (!noTui && process.stdin.isTTY) {
|
|
1077
|
+
try {
|
|
1078
|
+
const { TunnelTui } = await import("./TunnelTui-QZEWWH2H.js");
|
|
1079
|
+
const tui = new TunnelTui({
|
|
1080
|
+
urls,
|
|
1081
|
+
greet,
|
|
1082
|
+
tunnelConfig,
|
|
1083
|
+
// Skip the user-provided onStop if the daemon is already gone:
|
|
1084
|
+
onStop: async () => {
|
|
1085
|
+
if (client.isDaemonLost()) return;
|
|
1086
|
+
await onExit();
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
client.onStats((id, stats) => {
|
|
1090
|
+
if (id === tunnelId) tui.updateStats(stats);
|
|
1091
|
+
});
|
|
1092
|
+
client.onDisconnect((id, error, messages) => {
|
|
1093
|
+
if (id === tunnelId) tui.showDisconnectModal(error, messages);
|
|
1094
|
+
});
|
|
1095
|
+
client.onReconnecting((id, retryCnt) => {
|
|
1096
|
+
if (id === tunnelId) tui.updateReconnectingInfo(retryCnt);
|
|
1097
|
+
});
|
|
1098
|
+
client.onReconnected((id, newUrls) => {
|
|
1099
|
+
if (id === tunnelId) {
|
|
1100
|
+
tui.closeReconnectingInfo();
|
|
1101
|
+
tui.updateUrls(newUrls);
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
client.onReconnectionFailed((id, retryCnt) => {
|
|
1105
|
+
if (id === tunnelId) tui.updateReconnectionFailed(retryCnt);
|
|
1106
|
+
});
|
|
1107
|
+
client.onStopped((id) => {
|
|
1108
|
+
if (id === tunnelId) tui.stop();
|
|
1109
|
+
});
|
|
1110
|
+
wireDaemonLost(client, {
|
|
1111
|
+
onReconnecting: (attempt, max) => tui.updateReconnectingInfo(attempt, `Daemon disconnected \u2014 reconnecting (${attempt}/${max})...`),
|
|
1112
|
+
onReconnected: () => tui.closeReconnectingInfo(),
|
|
1113
|
+
onLost: () => tui.stop()
|
|
1114
|
+
});
|
|
1115
|
+
tui.start();
|
|
1116
|
+
await tui.waitUntilExit();
|
|
1117
|
+
} catch {
|
|
1118
|
+
}
|
|
1119
|
+
} else {
|
|
1120
|
+
client.onStats((id, stats) => {
|
|
1121
|
+
if (id === tunnelId) {
|
|
1122
|
+
process.stdout.write(`\r${pico.gray(`Connections: ${stats.numTotalConnections} | Bytes: ${stats.numTotalTxBytes}`)}`);
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
client.onDisconnect((id, error) => {
|
|
1126
|
+
if (id === tunnelId) printer_default.warn(`Disconnected: ${error}`);
|
|
1127
|
+
});
|
|
1128
|
+
client.onReconnected((id, newUrls) => {
|
|
1129
|
+
if (id === tunnelId) printer_default.success(`Reconnected: ${newUrls.join(", ")}`);
|
|
1130
|
+
});
|
|
1131
|
+
client.onStopped((id) => {
|
|
1132
|
+
if (id === tunnelId) {
|
|
1133
|
+
printer_default.print("\nTunnel stopped.");
|
|
1134
|
+
process.exit(0);
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
wireDaemonLost(client, {
|
|
1138
|
+
onReconnecting: (attempt, max) => printer_default.warn(`
|
|
1139
|
+
Daemon connection dropped \u2014 reconnecting (${attempt}/${max})...`),
|
|
1140
|
+
onReconnected: () => printer_default.success("Daemon reconnected."),
|
|
1141
|
+
onLost: () => {
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
await new Promise((resolve) => {
|
|
1145
|
+
installShutdownHandlers(async () => {
|
|
1146
|
+
printer_default.print(`
|
|
1147
|
+
${exitMessage}`);
|
|
1148
|
+
if (!client.isDaemonLost()) {
|
|
1149
|
+
try {
|
|
1150
|
+
await onExit();
|
|
1151
|
+
} catch {
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
client.close();
|
|
1155
|
+
resolve();
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
async function startForegroundViaDaemon(finalConfig) {
|
|
1161
|
+
if (!finalConfig.optional?.noTui && finalConfig.webDebugger === "") {
|
|
1162
|
+
const freePort = await getFreePort(finalConfig.webDebugger || "");
|
|
1163
|
+
finalConfig.webDebugger = `localhost:${freePort}`;
|
|
1164
|
+
}
|
|
1165
|
+
const client = await initTunnelClient();
|
|
1166
|
+
printer_default.startSpinner("Submitting tunnel to daemon...");
|
|
1167
|
+
const pending = await client.handleStartV2(finalConfig, true, SessionMode.Foreground);
|
|
1168
|
+
if (isErrorResponse(pending)) {
|
|
1169
|
+
if (pending.code === ErrorCode.TunnelAlreadyRunningError) {
|
|
1170
|
+
printer_default.stopSpinnerSuccess("Already running");
|
|
1171
|
+
await printAlreadyRunning(client, finalConfig.configId, finalConfig.name);
|
|
1172
|
+
client.close();
|
|
1173
|
+
process.exit(0);
|
|
1174
|
+
}
|
|
1175
|
+
printer_default.stopSpinnerFail("Failed to start");
|
|
1176
|
+
printer_default.error(`Failed to start tunnel: ${pending.message}`);
|
|
1177
|
+
client.close();
|
|
1178
|
+
process.exit(1);
|
|
1179
|
+
}
|
|
1180
|
+
const tunnelId = pending.tunnelid;
|
|
1181
|
+
printer_default.startSpinner(`Tunnel ${tunnelId.slice(0, 8)} created \u2014 ${pending.status?.state || "starting"}...`);
|
|
1182
|
+
await client.attach(tunnelId, "foreground");
|
|
1183
|
+
printer_default.startSpinner(`Tunnel ${tunnelId.slice(0, 8)} \u2014 waiting for connection...`);
|
|
1184
|
+
const outcome = await waitForTunnelLive(client, tunnelId);
|
|
1185
|
+
if ("error" in outcome) {
|
|
1186
|
+
printer_default.stopSpinnerFail("Failed to connect");
|
|
1187
|
+
printer_default.error(`Failed to start tunnel: ${outcome.error}`);
|
|
1188
|
+
client.close();
|
|
1189
|
+
process.exit(1);
|
|
1190
|
+
}
|
|
1191
|
+
printer_default.stopSpinnerSuccess(" Connected to Pinggy");
|
|
1192
|
+
const tunnel = await fetchTunnelV2(client, tunnelId);
|
|
1193
|
+
const urls = outcome.urls.length ? outcome.urls : tunnel?.remoteurls || [];
|
|
1194
|
+
const greetmsg = tunnel?.greetmsg || "";
|
|
1195
|
+
printer_default.success(pico.bold("Tunnel established!"));
|
|
1196
|
+
printer_default.print(pico.gray("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
1197
|
+
printer_default.info(pico.cyanBright("Remote URLs:"));
|
|
1198
|
+
printRemoteUrls(urls);
|
|
1199
|
+
printer_default.print(pico.gray("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
1200
|
+
if (greetmsg.includes("not authenticated")) {
|
|
1201
|
+
printer_default.warn(pico.yellowBright(greetmsg));
|
|
1202
|
+
} else if (greetmsg.includes("authenticated as")) {
|
|
1203
|
+
const emailMatch = /authenticated as (.+)/.exec(greetmsg);
|
|
1204
|
+
if (emailMatch) {
|
|
1205
|
+
printer_default.info(pico.cyanBright("Authenticated as: " + emailMatch[1]));
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
printer_default.print(pico.gray("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
1209
|
+
printer_default.print(pico.gray("\nPress Ctrl+C to stop the tunnel.\n"));
|
|
1210
|
+
await connectTui({
|
|
1211
|
+
client,
|
|
1212
|
+
tunnelId,
|
|
1213
|
+
urls,
|
|
1214
|
+
greet: greetmsg,
|
|
1215
|
+
tunnelConfig: finalConfig,
|
|
1216
|
+
noTui: !!finalConfig.optional?.noTui,
|
|
1217
|
+
onExit: async () => {
|
|
1218
|
+
await client.handleStop(tunnelId);
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
client.close();
|
|
1222
|
+
}
|
|
1223
|
+
async function waitForTunnelLive(client, tunnelId) {
|
|
1224
|
+
return new Promise((resolve) => {
|
|
1225
|
+
let settled = false;
|
|
1226
|
+
const done = (val) => {
|
|
1227
|
+
if (settled) return;
|
|
1228
|
+
settled = true;
|
|
1229
|
+
resolve(val);
|
|
1230
|
+
};
|
|
1231
|
+
client.onUrlReady((id, urls) => {
|
|
1232
|
+
if (id === tunnelId) done({ urls });
|
|
1233
|
+
});
|
|
1234
|
+
client.onError((id, message, isFatal) => {
|
|
1235
|
+
if (id === tunnelId && isFatal) done({ error: message });
|
|
1236
|
+
});
|
|
1237
|
+
client.onDisconnect((id, error) => {
|
|
1238
|
+
if (id === tunnelId) done({ error });
|
|
1239
|
+
});
|
|
1240
|
+
client.handleListV2().then((res) => {
|
|
1241
|
+
if (settled || isErrorResponse(res)) return;
|
|
1242
|
+
const t = res.find((x) => x.tunnelid === tunnelId);
|
|
1243
|
+
if (t?.remoteurls?.length) done({ urls: t.remoteurls });
|
|
1244
|
+
if (t?.status?.lastError?.isFatal) done({ error: t.status.lastError.message });
|
|
1245
|
+
}).catch(() => {
|
|
1246
|
+
});
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
async function fetchTunnelV2(client, tunnelId) {
|
|
1250
|
+
const res = await client.handleListV2();
|
|
1251
|
+
if (isErrorResponse(res)) return null;
|
|
1252
|
+
return res.find((t) => t.tunnelid === tunnelId) || null;
|
|
1253
|
+
}
|
|
1254
|
+
async function startBackgroundViaDaemon(finalConfig) {
|
|
1255
|
+
const client = await initTunnelClient();
|
|
1256
|
+
printer_default.info("Starting tunnel...");
|
|
1257
|
+
const result = await startTunnel(client, finalConfig, { onError: "fatal", mode: SessionMode.Detached });
|
|
1258
|
+
const tunnelId = result.tunnelid;
|
|
1259
|
+
printer_default.success(`Tunnel started (ID: ${tunnelId})`);
|
|
1260
|
+
printRemoteUrls(result.remoteurls);
|
|
1261
|
+
printer_default.print(pico.gray("\nTunnel running in background. Use 'pinggy ps' to list, 'pinggy stop " + tunnelId.slice(0, 8) + "' to stop."));
|
|
1262
|
+
client.close();
|
|
1263
|
+
}
|
|
1264
|
+
async function startMultipleForegroundViaDaemon(configs, values, positionals) {
|
|
1265
|
+
const client = await initTunnelClient();
|
|
1266
|
+
const startedIds = [];
|
|
1267
|
+
printer_default.print(pico.cyanBright(`Starting ${configs.length} tunnel(s)...`));
|
|
1268
|
+
for (const saved of configs) {
|
|
1269
|
+
const config = { ...saved.tunnelConfig, configId: saved.configId, name: saved.name };
|
|
1270
|
+
const result = await startTunnel(client, config, { label: saved.name, onError: "continue", mode: SessionMode.Foreground });
|
|
1271
|
+
if (!result) continue;
|
|
1272
|
+
startedIds.push(result.tunnelid);
|
|
1273
|
+
printer_default.success(`"${saved.name}" started`);
|
|
1274
|
+
printRemoteUrls(result.remoteurls);
|
|
1275
|
+
}
|
|
1276
|
+
if (startedIds.length === 0) {
|
|
1277
|
+
printer_default.error("No tunnels started.");
|
|
1278
|
+
client.close();
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
for (const id of startedIds) {
|
|
1282
|
+
await client.attach(id, "foreground");
|
|
1283
|
+
}
|
|
1284
|
+
printer_default.print(pico.gray("\nAll tunnels launched. Press Ctrl+C to stop.\n"));
|
|
1285
|
+
await waitForShutdownAndStopAll(client, startedIds);
|
|
1286
|
+
}
|
|
1287
|
+
async function startBackgroundTunnels(configs, values, positionals) {
|
|
1288
|
+
const client = await initTunnelClient();
|
|
1289
|
+
const buildConfig = configs.length === 1 ? (saved) => buildFinalConfig(values, positionals, saved.tunnelConfig) : (saved) => ({ ...saved.tunnelConfig, configId: saved.configId, name: saved.name });
|
|
1290
|
+
for (const saved of configs) {
|
|
1291
|
+
const finalConfig = buildConfig(saved);
|
|
1292
|
+
const result = await startTunnel(client, finalConfig, { label: saved.name, onError: "continue", mode: SessionMode.Detached });
|
|
1293
|
+
if (!result) continue;
|
|
1294
|
+
printer_default.success(`"${saved.name}" started (ID: ${result.tunnelid})`);
|
|
1295
|
+
printRemoteUrls(result.remoteurls);
|
|
1296
|
+
}
|
|
1297
|
+
printer_default.print(pico.gray("\nTunnel(s) running in background. Use 'pinggy ps' to list, 'pinggy stop <name|id>' to stop."));
|
|
1298
|
+
client.close();
|
|
1299
|
+
}
|
|
1300
|
+
async function startAutoStartTunnels() {
|
|
1301
|
+
const { getAutoStartConfigs: getAutoStartConfigs2 } = await import("./configStore-TSGRNOE3.js");
|
|
1302
|
+
const configs = getAutoStartConfigs2();
|
|
1303
|
+
if (configs.length === 0) {
|
|
1304
|
+
printer_default.warn("No configs marked for auto-start. Use: pinggy config auto <name>");
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
const client = await initTunnelClient();
|
|
1308
|
+
const startedIds = [];
|
|
1309
|
+
printer_default.print(pico.cyanBright(`Starting ${configs.length} auto-start tunnel(s)...`));
|
|
1310
|
+
for (const saved of configs) {
|
|
1311
|
+
const config = { ...saved.tunnelConfig, configId: saved.configId, name: saved.name };
|
|
1312
|
+
const result = await startTunnel(client, config, { label: saved.name, onError: "continue", mode: SessionMode.Foreground });
|
|
1313
|
+
if (!result) continue;
|
|
1314
|
+
startedIds.push(result.tunnelid);
|
|
1315
|
+
printer_default.success(`"${saved.name}" started`);
|
|
1316
|
+
printRemoteUrls(result.remoteurls);
|
|
1317
|
+
}
|
|
1318
|
+
if (startedIds.length === 0) {
|
|
1319
|
+
printer_default.error("No tunnels started.");
|
|
1320
|
+
client.close();
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
for (const id of startedIds) {
|
|
1324
|
+
await client.attach(id, "foreground");
|
|
1325
|
+
}
|
|
1326
|
+
printer_default.print(pico.gray("\nAll auto-start tunnels launched. Press Ctrl+C to stop.\n"));
|
|
1327
|
+
await waitForShutdownAndStopAll(client, startedIds);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// src/cli/buildAndStartTunnel.ts
|
|
1331
|
+
function analyzeIntent(values, positionals) {
|
|
1332
|
+
const hasTunnelFlag = !!(Array.isArray(values.R) && values.R.length > 0 || Array.isArray(values.L) && values.L.length > 0 || values.localport || values.type || values.conf || values.serve);
|
|
1333
|
+
const parsed = parseUsers(positionals, values.token);
|
|
1334
|
+
const hasServerFromArgs = !!parsed.server;
|
|
1335
|
+
if (hasTunnelFlag || hasServerFromArgs) return { kind: "tunnel" };
|
|
1336
|
+
if (positionals.length > 0) {
|
|
1337
|
+
return {
|
|
1338
|
+
kind: "invalid",
|
|
1339
|
+
message: `Unrecognized argument(s): ${positionals.join(" ")}
|
|
1340
|
+
Usage: pinggy -l 3000
|
|
1341
|
+
Try: pinggy --help`
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
const rmToken = values["remote-management"];
|
|
1345
|
+
if (typeof rmToken === "string" && rmToken.trim().length > 0) {
|
|
1346
|
+
return { kind: "remote-only" };
|
|
1347
|
+
}
|
|
1348
|
+
return {
|
|
1349
|
+
kind: "invalid",
|
|
1350
|
+
message: "No tunnel specified.\nUsage: pinggy -l 3000 or pinggy <token>@<server>\nTry: pinggy --help"
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
async function buildAndStartTunnel(values, positionals) {
|
|
1354
|
+
const intent = analyzeIntent(values, positionals);
|
|
1355
|
+
if (intent.kind === "invalid") {
|
|
1356
|
+
printer_default.error(intent.message);
|
|
1357
|
+
process.exit(1);
|
|
1358
|
+
}
|
|
1359
|
+
if (intent.kind === "remote-only") {
|
|
1360
|
+
printer_default.print("Remote management mode. Press Ctrl+C to stop.");
|
|
1361
|
+
await initRemoteManagement(
|
|
1362
|
+
values,
|
|
1363
|
+
/* blocking */
|
|
1364
|
+
true
|
|
1365
|
+
);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
await initRemoteManagement(
|
|
1369
|
+
values,
|
|
1370
|
+
/* blocking */
|
|
1371
|
+
false
|
|
1372
|
+
);
|
|
1373
|
+
logger.debug("Building final config from CLI values and positionals", { values, positionals });
|
|
1374
|
+
const finalConfig = buildFinalConfig(values, positionals);
|
|
1375
|
+
logger.debug("Final configuration built", finalConfig);
|
|
1376
|
+
if (values.save) {
|
|
1377
|
+
const name = values.name;
|
|
1378
|
+
if (!name) {
|
|
1379
|
+
printer_default.error("--save requires --name to specify a name for the tunnel config.");
|
|
1380
|
+
process.exit(1);
|
|
1381
|
+
}
|
|
1382
|
+
const nameErr = validateName(name);
|
|
1383
|
+
if (nameErr) {
|
|
1384
|
+
printer_default.error(nameErr.message);
|
|
1385
|
+
process.exit(1);
|
|
1386
|
+
}
|
|
1387
|
+
const autoStart = !!values.auto;
|
|
1388
|
+
saveConfig(name, finalConfig.configId, finalConfig, autoStart);
|
|
1389
|
+
printer_default.success(`Config "${name}" saved.`);
|
|
1390
|
+
}
|
|
1391
|
+
if (values.b) {
|
|
1392
|
+
await startBackgroundViaDaemon(finalConfig);
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
await startForegroundViaDaemon(finalConfig);
|
|
1396
|
+
}
|
|
1397
|
+
async function initRemoteManagement(values, blocking) {
|
|
1398
|
+
const rmToken = values["remote-management"];
|
|
1399
|
+
if (typeof rmToken !== "string" || rmToken.trim().length === 0) return;
|
|
1400
|
+
const handler = await TunnelClient.forRemoteManagement();
|
|
1401
|
+
handler.onDaemonLost((reason, detail) => {
|
|
1402
|
+
printer_default.error(daemonLostMessage(reason, detail));
|
|
1403
|
+
setImmediate(() => process.exit(3));
|
|
1404
|
+
});
|
|
1405
|
+
const config = {
|
|
1406
|
+
apiKey: rmToken,
|
|
1407
|
+
serverUrl: buildRemoteManagementWsUrl(values["manage"])
|
|
1408
|
+
};
|
|
1409
|
+
try {
|
|
1410
|
+
if (blocking) {
|
|
1411
|
+
await initiateRemoteManagement(config, handler);
|
|
1412
|
+
} else {
|
|
1413
|
+
await startRemoteManagement(config, handler);
|
|
1414
|
+
}
|
|
1415
|
+
} catch (e) {
|
|
1416
|
+
logger.error("Failed to initiate remote management:", e);
|
|
1417
|
+
printer_default.fatal(e);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// src/cli/subcommand/handlers/daemonCommandsHandler.ts
|
|
1422
|
+
import pico2 from "picocolors";
|
|
1423
|
+
|
|
1424
|
+
// src/daemon/lifecycle/serviceInstaller.ts
|
|
1425
|
+
import os2 from "os";
|
|
1426
|
+
import fs2 from "fs";
|
|
1427
|
+
import path2 from "path";
|
|
1428
|
+
import { execSync, execFileSync } from "child_process";
|
|
1429
|
+
var SERVICE_LABEL = "io.pinggy.agent";
|
|
1430
|
+
var SYSTEMD_SERVICE_NAME = "pinggy";
|
|
1431
|
+
function resolveBinary() {
|
|
1432
|
+
const isPkg = "pkg" in process;
|
|
1433
|
+
if (isPkg) return { program: process.execPath, args: [] };
|
|
1434
|
+
try {
|
|
1435
|
+
const bin = execSync(os2.platform() === "win32" ? "where pinggy" : "which pinggy", {
|
|
1436
|
+
encoding: "utf-8"
|
|
1437
|
+
}).trim().split(/\r?\n/)[0];
|
|
1438
|
+
return { program: bin, args: [] };
|
|
1439
|
+
} catch {
|
|
1440
|
+
return { program: process.execPath, args: [process.argv[1]] };
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
function getSystemdServicePath() {
|
|
1444
|
+
const dir = path2.join(os2.homedir(), ".config", "systemd", "user");
|
|
1445
|
+
return path2.join(dir, `${SYSTEMD_SERVICE_NAME}.service`);
|
|
1446
|
+
}
|
|
1447
|
+
function generateSystemdUnit(bin) {
|
|
1448
|
+
const execStart = [bin.program, ...bin.args, "--_daemon-child"].join(" ");
|
|
1449
|
+
return `[Unit]
|
|
1450
|
+
Description=Pinggy Tunnel Daemon
|
|
1451
|
+
After=network-online.target
|
|
1452
|
+
Wants=network-online.target
|
|
1453
|
+
|
|
1454
|
+
[Service]
|
|
1455
|
+
Type=simple
|
|
1456
|
+
ExecStart=${execStart}
|
|
1457
|
+
Restart=on-failure
|
|
1458
|
+
RestartSec=5
|
|
1459
|
+
|
|
1460
|
+
[Install]
|
|
1461
|
+
WantedBy=default.target
|
|
1462
|
+
`;
|
|
1463
|
+
}
|
|
1464
|
+
function installSystemd() {
|
|
1465
|
+
const bin = resolveBinary();
|
|
1466
|
+
const servicePath = getSystemdServicePath();
|
|
1467
|
+
const dir = path2.dirname(servicePath);
|
|
1468
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
1469
|
+
fs2.writeFileSync(servicePath, generateSystemdUnit(bin), "utf-8");
|
|
1470
|
+
execSync("systemctl --user daemon-reload");
|
|
1471
|
+
execSync(`systemctl --user enable ${SYSTEMD_SERVICE_NAME}`);
|
|
1472
|
+
execSync(`systemctl --user start ${SYSTEMD_SERVICE_NAME}`);
|
|
1473
|
+
logger.info("systemd user service installed and started", { servicePath });
|
|
1474
|
+
console.log(`Service installed: ${servicePath}`);
|
|
1475
|
+
console.log(`Enable at boot: loginctl enable-linger ${os2.userInfo().username}`);
|
|
1476
|
+
}
|
|
1477
|
+
function uninstallSystemd() {
|
|
1478
|
+
const servicePath = getSystemdServicePath();
|
|
1479
|
+
try {
|
|
1480
|
+
execSync(`systemctl --user stop ${SYSTEMD_SERVICE_NAME} 2>/dev/null || true`);
|
|
1481
|
+
execSync(`systemctl --user disable ${SYSTEMD_SERVICE_NAME} 2>/dev/null || true`);
|
|
1482
|
+
} catch {
|
|
1483
|
+
}
|
|
1484
|
+
if (fs2.existsSync(servicePath)) {
|
|
1485
|
+
fs2.unlinkSync(servicePath);
|
|
1486
|
+
execSync("systemctl --user daemon-reload");
|
|
1487
|
+
console.log(`Service removed: ${servicePath}`);
|
|
1488
|
+
} else {
|
|
1489
|
+
console.log("No systemd service found to remove.");
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
function getLaunchdPlistPath() {
|
|
1493
|
+
return path2.join(os2.homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
|
|
1494
|
+
}
|
|
1495
|
+
function generateLaunchdPlist(bin) {
|
|
1496
|
+
const allArgs = [bin.program, ...bin.args, "--_daemon-child"];
|
|
1497
|
+
const programArgs = allArgs.map((p) => ` <string>${p}</string>`).join("\n");
|
|
1498
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1499
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
1500
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1501
|
+
<plist version="1.0">
|
|
1502
|
+
<dict>
|
|
1503
|
+
<key>Label</key>
|
|
1504
|
+
<string>${SERVICE_LABEL}</string>
|
|
1505
|
+
<key>ProgramArguments</key>
|
|
1506
|
+
<array>
|
|
1507
|
+
${programArgs}
|
|
1508
|
+
</array>
|
|
1509
|
+
<key>RunAtLoad</key>
|
|
1510
|
+
<true/>
|
|
1511
|
+
<key>KeepAlive</key>
|
|
1512
|
+
<false/>
|
|
1513
|
+
</dict>
|
|
1514
|
+
</plist>
|
|
1515
|
+
`;
|
|
1516
|
+
}
|
|
1517
|
+
function installLaunchd() {
|
|
1518
|
+
const bin = resolveBinary();
|
|
1519
|
+
const plistPath = getLaunchdPlistPath();
|
|
1520
|
+
const dir = path2.dirname(plistPath);
|
|
1521
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
1522
|
+
fs2.writeFileSync(plistPath, generateLaunchdPlist(bin), "utf-8");
|
|
1523
|
+
const uid = process.getuid?.() ?? 501;
|
|
1524
|
+
try {
|
|
1525
|
+
execSync(`launchctl bootout gui/${uid}/${SERVICE_LABEL} 2>/dev/null || true`);
|
|
1526
|
+
} catch {
|
|
1527
|
+
}
|
|
1528
|
+
try {
|
|
1529
|
+
execSync(`launchctl bootstrap gui/${uid} ${plistPath}`);
|
|
1530
|
+
} catch {
|
|
1531
|
+
try {
|
|
1532
|
+
execSync(`launchctl load -w ${plistPath}`);
|
|
1533
|
+
} catch {
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
logger.info("launchd agent installed and started", { plistPath });
|
|
1537
|
+
console.log(`Service installed and started: ${plistPath}`);
|
|
1538
|
+
}
|
|
1539
|
+
function uninstallLaunchd() {
|
|
1540
|
+
const plistPath = getLaunchdPlistPath();
|
|
1541
|
+
const uid = process.getuid?.() ?? 501;
|
|
1542
|
+
try {
|
|
1543
|
+
execSync(`launchctl bootout gui/${uid}/${SERVICE_LABEL} 2>/dev/null || true`);
|
|
1544
|
+
} catch {
|
|
1545
|
+
try {
|
|
1546
|
+
execSync(`launchctl unload ${plistPath} 2>/dev/null || true`);
|
|
1547
|
+
} catch {
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
if (fs2.existsSync(plistPath)) {
|
|
1551
|
+
fs2.unlinkSync(plistPath);
|
|
1552
|
+
console.log(`Service removed: ${plistPath}`);
|
|
1553
|
+
} else {
|
|
1554
|
+
console.log("No launchd agent found to remove.");
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
var WINDOWS_TASK_NAME = "PinggyDaemon";
|
|
1558
|
+
function generateTaskXml(bin) {
|
|
1559
|
+
const command = bin.program;
|
|
1560
|
+
const args = [...bin.args, "--_daemon-child"].join(" ");
|
|
1561
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
1562
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
1563
|
+
<Triggers>
|
|
1564
|
+
<LogonTrigger>
|
|
1565
|
+
<Enabled>true</Enabled>
|
|
1566
|
+
</LogonTrigger>
|
|
1567
|
+
</Triggers>
|
|
1568
|
+
<Principals>
|
|
1569
|
+
<Principal id="Author">
|
|
1570
|
+
<LogonType>InteractiveToken</LogonType>
|
|
1571
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
1572
|
+
</Principal>
|
|
1573
|
+
</Principals>
|
|
1574
|
+
<Settings>
|
|
1575
|
+
<Hidden>true</Hidden>
|
|
1576
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
1577
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
1578
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
1579
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
1580
|
+
<RestartOnFailure>
|
|
1581
|
+
<Interval>PT1M</Interval>
|
|
1582
|
+
<Count>3</Count>
|
|
1583
|
+
</RestartOnFailure>
|
|
1584
|
+
</Settings>
|
|
1585
|
+
<Actions>
|
|
1586
|
+
<Exec>
|
|
1587
|
+
<Command>${command}</Command>${args ? `
|
|
1588
|
+
<Arguments>${args}</Arguments>` : ""}
|
|
1589
|
+
</Exec>
|
|
1590
|
+
</Actions>
|
|
1591
|
+
</Task>`;
|
|
1592
|
+
}
|
|
1593
|
+
function installWindows() {
|
|
1594
|
+
const bin = resolveBinary();
|
|
1595
|
+
const xml = generateTaskXml(bin);
|
|
1596
|
+
const tmpPath = path2.join(os2.tmpdir(), `${WINDOWS_TASK_NAME}.xml`);
|
|
1597
|
+
fs2.writeFileSync(tmpPath, "\uFEFF" + xml, "utf16le");
|
|
1598
|
+
try {
|
|
1599
|
+
execFileSync("schtasks", [
|
|
1600
|
+
"/Create",
|
|
1601
|
+
"/TN",
|
|
1602
|
+
WINDOWS_TASK_NAME,
|
|
1603
|
+
"/XML",
|
|
1604
|
+
tmpPath,
|
|
1605
|
+
"/F"
|
|
1606
|
+
], { stdio: "inherit" });
|
|
1607
|
+
} finally {
|
|
1608
|
+
try {
|
|
1609
|
+
fs2.unlinkSync(tmpPath);
|
|
1610
|
+
} catch {
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
execFileSync("schtasks", ["/Run", "/TN", WINDOWS_TASK_NAME], { stdio: "inherit" });
|
|
1614
|
+
console.log(`Scheduled task "${WINDOWS_TASK_NAME}" created and started (runs at login, hidden).`);
|
|
1615
|
+
}
|
|
1616
|
+
function uninstallWindows() {
|
|
1617
|
+
try {
|
|
1618
|
+
execFileSync("schtasks", ["/End", "/TN", WINDOWS_TASK_NAME], { stdio: "ignore" });
|
|
1619
|
+
} catch {
|
|
1620
|
+
}
|
|
1621
|
+
try {
|
|
1622
|
+
execFileSync("schtasks", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], { stdio: "inherit" });
|
|
1623
|
+
console.log(`Scheduled task "${WINDOWS_TASK_NAME}" removed.`);
|
|
1624
|
+
} catch {
|
|
1625
|
+
console.log("No scheduled task found to remove.");
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
function installService() {
|
|
1629
|
+
const platform2 = os2.platform();
|
|
1630
|
+
const bin = resolveBinary();
|
|
1631
|
+
if (!("pkg" in process) && bin.program !== process.execPath) {
|
|
1632
|
+
console.warn(
|
|
1633
|
+
"Warning: Using npm-installed binary. The path may change if Node.js is updated.\nConsider using a standalone binary (pkg) for system services."
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
switch (platform2) {
|
|
1637
|
+
case "linux":
|
|
1638
|
+
installSystemd();
|
|
1639
|
+
break;
|
|
1640
|
+
case "darwin":
|
|
1641
|
+
installLaunchd();
|
|
1642
|
+
break;
|
|
1643
|
+
case "win32":
|
|
1644
|
+
installWindows();
|
|
1645
|
+
break;
|
|
1646
|
+
default:
|
|
1647
|
+
console.error(`Unsupported platform: ${platform2}`);
|
|
1648
|
+
process.exit(1);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
function uninstallService() {
|
|
1652
|
+
const platform2 = os2.platform();
|
|
1653
|
+
switch (platform2) {
|
|
1654
|
+
case "linux":
|
|
1655
|
+
uninstallSystemd();
|
|
1656
|
+
break;
|
|
1657
|
+
case "darwin":
|
|
1658
|
+
uninstallLaunchd();
|
|
1659
|
+
break;
|
|
1660
|
+
case "win32":
|
|
1661
|
+
uninstallWindows();
|
|
1662
|
+
break;
|
|
1663
|
+
default:
|
|
1664
|
+
console.error(`Unsupported platform: ${platform2}`);
|
|
1665
|
+
process.exit(1);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// src/utils/helpMessages.ts
|
|
1670
|
+
function printDaemonHelp() {
|
|
1671
|
+
console.log("\nUsage: pinggy daemon <command>");
|
|
1672
|
+
console.log(" pinggy d <command>\n");
|
|
1673
|
+
console.log("Commands:");
|
|
1674
|
+
console.log(" start Start the daemon process");
|
|
1675
|
+
console.log(" stop Stop the daemon (stops all tunnels)");
|
|
1676
|
+
console.log(" status Show daemon PID and uptime");
|
|
1677
|
+
console.log("Tunnel operations:");
|
|
1678
|
+
console.log(" pinggy ps List running tunnels");
|
|
1679
|
+
console.log(" pinggy stop <name|id> Stop a specific tunnel");
|
|
1680
|
+
console.log(" pinggy attach <name|id> Re-attach TUI to a tunnel\n");
|
|
1681
|
+
}
|
|
1682
|
+
function printLogHelp() {
|
|
1683
|
+
console.log("\nUsage: pinggy log <verb> [options]\n");
|
|
1684
|
+
console.log("Commands:");
|
|
1685
|
+
console.log(" level Print current log level");
|
|
1686
|
+
console.log(" level debug|info|error Set log level");
|
|
1687
|
+
console.log(" path Print daemon log path");
|
|
1688
|
+
console.log(" path <name|id> Print tunnel log path\n");
|
|
1689
|
+
}
|
|
1690
|
+
function printConfigHelp() {
|
|
1691
|
+
console.log("\nUsage: pinggy config <command> [name] [options]\n");
|
|
1692
|
+
console.log("Commands:");
|
|
1693
|
+
console.log(" list List all saved configs");
|
|
1694
|
+
console.log(" show <name> Show config details");
|
|
1695
|
+
console.log(" save <name> [tunnel flags] Save a tunnel config");
|
|
1696
|
+
console.log(" update <name> [tunnel flags] Update a saved config");
|
|
1697
|
+
console.log(" delete <name> Delete a saved config");
|
|
1698
|
+
console.log(" auto <name> Enable auto-start");
|
|
1699
|
+
console.log(" noauto <name> Disable auto-start\n");
|
|
1700
|
+
}
|
|
1701
|
+
function printStartHelp() {
|
|
1702
|
+
console.log("\nUsage: pinggy start <name> [options]\n");
|
|
1703
|
+
console.log("Examples:");
|
|
1704
|
+
console.log(" pinggy start my-tunnel Start a saved tunnel");
|
|
1705
|
+
console.log(" pinggy start my-tunnel -l 4000 Start with override");
|
|
1706
|
+
console.log(" pinggy start tunnela tunnelb Start multiple tunnels");
|
|
1707
|
+
console.log(" pinggy start --all Start all auto-start tunnels\n");
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// src/cli/subcommand/handlers/daemonCommandsHandler.ts
|
|
1711
|
+
async function handleDaemon(args) {
|
|
1712
|
+
if (args.length === 0) {
|
|
1713
|
+
printDaemonHelp();
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const verb = args[0];
|
|
1717
|
+
switch (verb) {
|
|
1718
|
+
case "start":
|
|
1719
|
+
await handleDaemonStart();
|
|
1720
|
+
return;
|
|
1721
|
+
case "stop":
|
|
1722
|
+
await handleDaemonStop();
|
|
1723
|
+
return;
|
|
1724
|
+
case "status":
|
|
1725
|
+
handleDaemonStatus();
|
|
1726
|
+
return;
|
|
1727
|
+
case "service-install":
|
|
1728
|
+
installService();
|
|
1729
|
+
return;
|
|
1730
|
+
case "service-uninstall":
|
|
1731
|
+
uninstallService();
|
|
1732
|
+
return;
|
|
1733
|
+
default:
|
|
1734
|
+
printer_default.error(`Unknown daemon command: "${verb}"`);
|
|
1735
|
+
printDaemonHelp();
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
async function handleDaemonStart() {
|
|
1740
|
+
if (isDaemonRunning()) {
|
|
1741
|
+
const info = getDaemonInfo();
|
|
1742
|
+
printer_default.print(pico2.yellow(`Daemon already running (PID ${info?.pid}, port ${info?.port}). Use: pinggy daemon status for details.`));
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
const autoConfigs = getAutoStartConfigs();
|
|
1746
|
+
if (autoConfigs.length > 0) {
|
|
1747
|
+
printer_default.print(pico2.cyanBright(`Starting daemon with ${autoConfigs.length} auto-start tunnel(s):`));
|
|
1748
|
+
for (const c of autoConfigs) {
|
|
1749
|
+
printer_default.print(` ${pico2.bold(c.name)} (${c.configId.slice(0, 8)})`);
|
|
1750
|
+
}
|
|
1751
|
+
} else {
|
|
1752
|
+
printer_default.print(pico2.cyanBright("Starting daemon..."));
|
|
1753
|
+
}
|
|
1754
|
+
try {
|
|
1755
|
+
const info = await startDaemon();
|
|
1756
|
+
printer_default.success(`Daemon started PID ${info.pid}.`);
|
|
1757
|
+
} catch (err) {
|
|
1758
|
+
printer_default.error(`Failed to start daemon: ${errorMessage(err)}`);
|
|
1759
|
+
process.exit(1);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
async function handleDaemonStop() {
|
|
1763
|
+
if (!isDaemonRunning()) {
|
|
1764
|
+
printer_default.print(pico2.yellow("No daemon is running."));
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
const result = await stopDaemon();
|
|
1768
|
+
if (result.ok) {
|
|
1769
|
+
printer_default.success("Daemon stopped.");
|
|
1770
|
+
} else {
|
|
1771
|
+
printer_default.error(result.error);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
function handleDaemonStatus() {
|
|
1775
|
+
const info = getDaemonInfo();
|
|
1776
|
+
if (!info) {
|
|
1777
|
+
printer_default.print(pico2.yellow("No daemon is running. Start with: pinggy daemon start"));
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
const uptimeSec = Math.floor((Date.now() - new Date(info.startedAt).getTime()) / 1e3);
|
|
1781
|
+
const h = Math.floor(uptimeSec / 3600);
|
|
1782
|
+
const m = Math.floor(uptimeSec % 3600 / 60);
|
|
1783
|
+
const s = uptimeSec % 60;
|
|
1784
|
+
const uptimeStr = h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
1785
|
+
printer_default.print(pico2.cyanBright("Daemon Status"));
|
|
1786
|
+
printer_default.print(` PID: ${info.pid}`);
|
|
1787
|
+
printer_default.print(` Port: ${info.port}`);
|
|
1788
|
+
printer_default.print(` Started: ${info.startedAt}`);
|
|
1789
|
+
printer_default.print(` Uptime: ${uptimeStr}`);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// src/cli/subcommand/handlers/psCommand.ts
|
|
1793
|
+
import pico3 from "picocolors";
|
|
1794
|
+
async function handlePs() {
|
|
1795
|
+
const client = new TunnelClient();
|
|
1796
|
+
try {
|
|
1797
|
+
await client.ensureDaemon();
|
|
1798
|
+
} catch (err) {
|
|
1799
|
+
printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
try {
|
|
1803
|
+
const tunnels = await client.handleListV2();
|
|
1804
|
+
if (isErrorResponse(tunnels)) {
|
|
1805
|
+
printer_default.error(`Failed to list tunnels: ${tunnels.message}`);
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
if (tunnels.length === 0) {
|
|
1809
|
+
printer_default.print("No tunnels running.");
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
const header = `${pad("ID", 14)} ${pad("NAME", 16)} ${pad("STATUS", 10)} ${pad("LOCAL", 20)} URL`;
|
|
1813
|
+
printer_default.print(pico3.gray(header));
|
|
1814
|
+
printer_default.print(pico3.gray("\u2500".repeat(90)));
|
|
1815
|
+
for (const t of tunnels) {
|
|
1816
|
+
const id = t.tunnelid.slice(0, 12);
|
|
1817
|
+
const name = t.tunnelconfig?.name || "-";
|
|
1818
|
+
const status = t.status.state;
|
|
1819
|
+
const local = getLocalAddress(t.tunnelconfig);
|
|
1820
|
+
const url = t.remoteurls?.[0] || "-";
|
|
1821
|
+
const statusColor = status === "running" ? pico3.green : status === "starting" ? pico3.yellow : pico3.red;
|
|
1822
|
+
printer_default.print(
|
|
1823
|
+
`${pico3.cyan(pad(id, 14))} ${pad(name, 16)} ${statusColor(pad(status, 10))} ${pad(local, 20)} ${pico3.magentaBright(url)}`
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
} catch (err) {
|
|
1827
|
+
printer_default.error(`Failed to list tunnels: ${errorMessage(err)}`);
|
|
1828
|
+
} finally {
|
|
1829
|
+
client.close();
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
function pad(str, len) {
|
|
1833
|
+
return str.length >= len ? str.slice(0, len) : str + " ".repeat(len - str.length);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// src/cli/subcommand/handlers/stopCommand.ts
|
|
1837
|
+
async function handleStop(args) {
|
|
1838
|
+
if (args.length === 0) {
|
|
1839
|
+
printer_default.error("Usage: pinggy stop <name|id> [<name|id> ...]");
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
const client = new TunnelClient();
|
|
1843
|
+
try {
|
|
1844
|
+
await client.ensureDaemon();
|
|
1845
|
+
} catch (err) {
|
|
1846
|
+
printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
try {
|
|
1850
|
+
const tunnels = await client.handleListV2();
|
|
1851
|
+
if (isErrorResponse(tunnels)) {
|
|
1852
|
+
printer_default.error(`Failed to list tunnels: ${tunnels.message}`);
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
const targets = resolveTargets(tunnels, args);
|
|
1856
|
+
if (targets.matched.length === 0) {
|
|
1857
|
+
printer_default.error(`No tunnel found matching: ${args.join(", ")}. Use: pinggy ps`);
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
for (const { input, tunnel } of targets.matched) {
|
|
1861
|
+
const result = await client.handleStop(tunnel.tunnelid);
|
|
1862
|
+
if (isErrorResponse(result)) {
|
|
1863
|
+
printer_default.error(`Failed to stop "${input}": ${result.message}`);
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
const name = tunnel.tunnelconfig.name || tunnel.tunnelid.slice(0, 12);
|
|
1867
|
+
printer_default.success(`Tunnel "${name}" stopped.`);
|
|
1868
|
+
}
|
|
1869
|
+
for (const m of targets.missing) {
|
|
1870
|
+
printer_default.warn(`No tunnel found matching "${m}".`);
|
|
1871
|
+
}
|
|
1872
|
+
} catch (err) {
|
|
1873
|
+
printer_default.error(`Failed to stop tunnel: ${errorMessage(err)}`);
|
|
1874
|
+
} finally {
|
|
1875
|
+
client.close();
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
function resolveTargets(tunnels, args) {
|
|
1879
|
+
if (args.length > 1) {
|
|
1880
|
+
const input = args.join(" ");
|
|
1881
|
+
const tunnel = findTunnel(tunnels, input);
|
|
1882
|
+
if (tunnel) return { matched: [{ input, tunnel }], missing: [] };
|
|
1883
|
+
}
|
|
1884
|
+
const matched = [];
|
|
1885
|
+
const missing = [];
|
|
1886
|
+
for (const input of args) {
|
|
1887
|
+
const tunnel = findTunnel(tunnels, input);
|
|
1888
|
+
if (tunnel) matched.push({ input, tunnel });
|
|
1889
|
+
else missing.push(input);
|
|
1890
|
+
}
|
|
1891
|
+
return { matched, missing };
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// src/cli/subcommand/handlers/attachCommand.ts
|
|
1895
|
+
import pico4 from "picocolors";
|
|
1896
|
+
async function handleAttach(args) {
|
|
1897
|
+
if (args.length === 0) {
|
|
1898
|
+
printer_default.error("Usage: pinggy attach <name|id>");
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
const nameOrId = args[0];
|
|
1902
|
+
const client = new TunnelClient();
|
|
1903
|
+
try {
|
|
1904
|
+
await client.ensureDaemon();
|
|
1905
|
+
} catch (err) {
|
|
1906
|
+
printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
try {
|
|
1910
|
+
const tunnels = await client.handleListV2();
|
|
1911
|
+
if (isErrorResponse(tunnels)) {
|
|
1912
|
+
printer_default.error(`Failed to list tunnels: ${tunnels.message}`);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
const match = findTunnel(tunnels, nameOrId);
|
|
1916
|
+
if (!match) {
|
|
1917
|
+
printer_default.error(`No running tunnel found matching "${nameOrId}". Use: pinggy ps`);
|
|
1918
|
+
client.close();
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
const tunnelId = match.tunnelid;
|
|
1922
|
+
const name = match.tunnelconfig?.name || tunnelId.slice(0, 12);
|
|
1923
|
+
printer_default.print(pico4.cyanBright(`Attaching to tunnel "${name}"...`));
|
|
1924
|
+
const attachMode = match.mode ?? "detached";
|
|
1925
|
+
await client.attach(tunnelId, attachMode);
|
|
1926
|
+
const urls = match.remoteurls || [];
|
|
1927
|
+
if (urls.length > 0) {
|
|
1928
|
+
printer_default.print("");
|
|
1929
|
+
for (const url of urls) {
|
|
1930
|
+
printer_default.print(" " + pico4.magentaBright(url));
|
|
1931
|
+
}
|
|
1932
|
+
printer_default.print("");
|
|
1933
|
+
}
|
|
1934
|
+
await connectTui({
|
|
1935
|
+
client,
|
|
1936
|
+
tunnelId,
|
|
1937
|
+
urls,
|
|
1938
|
+
greet: match.greetmsg || "",
|
|
1939
|
+
tunnelConfig: match.tunnelconfig || {},
|
|
1940
|
+
exitMessage: "Detaching...",
|
|
1941
|
+
onExit: () => Promise.resolve(client.detach(tunnelId))
|
|
1942
|
+
});
|
|
1943
|
+
} catch (err) {
|
|
1944
|
+
printer_default.error(`Failed to attach: ${errorMessage(err)}`);
|
|
1945
|
+
} finally {
|
|
1946
|
+
client.close();
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// src/cli/subcommand/handlers/logsCommand.ts
|
|
1951
|
+
import fs3 from "fs";
|
|
1952
|
+
async function handleLogs(args, follow) {
|
|
1953
|
+
const arg = args.find((a) => !a.startsWith("-"));
|
|
1954
|
+
const client = new TunnelClient();
|
|
1955
|
+
try {
|
|
1956
|
+
await client.ensureDaemon();
|
|
1957
|
+
} catch (err) {
|
|
1958
|
+
printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
let filePath;
|
|
1962
|
+
try {
|
|
1963
|
+
if (arg) {
|
|
1964
|
+
const result = await client.resolveLogPath(arg);
|
|
1965
|
+
if (result.status === "config-only") {
|
|
1966
|
+
printer_default.error(`Tunnel "${arg}" is saved but has not been started yet. No logs available.`);
|
|
1967
|
+
client.close();
|
|
1968
|
+
process.exit(1);
|
|
1969
|
+
}
|
|
1970
|
+
if (result.status === "not-found") {
|
|
1971
|
+
printer_default.error(`No tunnel or log file matching "${arg}".`);
|
|
1972
|
+
client.close();
|
|
1973
|
+
process.exit(1);
|
|
1974
|
+
}
|
|
1975
|
+
if (!result.path) {
|
|
1976
|
+
printer_default.error(`No log path returned for "${arg}".`);
|
|
1977
|
+
client.close();
|
|
1978
|
+
process.exit(1);
|
|
1979
|
+
}
|
|
1980
|
+
filePath = result.path;
|
|
1981
|
+
} else {
|
|
1982
|
+
const paths = await client.getLogPaths();
|
|
1983
|
+
filePath = paths.daemon;
|
|
1984
|
+
}
|
|
1985
|
+
} catch (err) {
|
|
1986
|
+
printer_default.error(`Failed to resolve log path: ${errorMessage(err)}`);
|
|
1987
|
+
client.close();
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
client.close();
|
|
1991
|
+
if (!filePath || !fs3.existsSync(filePath)) {
|
|
1992
|
+
printer_default.error(`Log file not found: ${filePath}`);
|
|
1993
|
+
process.exit(1);
|
|
1994
|
+
}
|
|
1995
|
+
if (follow) {
|
|
1996
|
+
await followFile(filePath);
|
|
1997
|
+
} else {
|
|
1998
|
+
printTail(filePath, 100);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
function printTail(filePath, lines) {
|
|
2002
|
+
const content = fs3.readFileSync(filePath, "utf-8");
|
|
2003
|
+
const allLines = content.split("\n");
|
|
2004
|
+
const tail = allLines.slice(-lines).join("\n");
|
|
2005
|
+
process.stdout.write(tail);
|
|
2006
|
+
if (!tail.endsWith("\n")) process.stdout.write("\n");
|
|
2007
|
+
}
|
|
2008
|
+
async function followFile(filePath) {
|
|
2009
|
+
if (fs3.existsSync(filePath)) printTail(filePath, 20);
|
|
2010
|
+
let fileSize = fs3.existsSync(filePath) ? fs3.statSync(filePath).size : 0;
|
|
2011
|
+
let watcher = null;
|
|
2012
|
+
const openStream = () => {
|
|
2013
|
+
const stream = fs3.createReadStream(filePath, { start: fileSize, encoding: "utf-8" });
|
|
2014
|
+
stream.on("data", (chunk) => {
|
|
2015
|
+
process.stdout.write(chunk);
|
|
2016
|
+
fileSize += Buffer.byteLength(chunk, "utf-8");
|
|
2017
|
+
});
|
|
2018
|
+
stream.on("error", () => {
|
|
2019
|
+
});
|
|
2020
|
+
};
|
|
2021
|
+
watcher = fs3.watch(filePath, { persistent: true }, (event) => {
|
|
2022
|
+
if (event === "change") {
|
|
2023
|
+
const stat = fs3.existsSync(filePath) ? fs3.statSync(filePath) : null;
|
|
2024
|
+
if (!stat || stat.size < fileSize) {
|
|
2025
|
+
fileSize = 0;
|
|
2026
|
+
}
|
|
2027
|
+
openStream();
|
|
2028
|
+
}
|
|
2029
|
+
});
|
|
2030
|
+
await new Promise((resolve) => {
|
|
2031
|
+
process.on("SIGINT", () => {
|
|
2032
|
+
watcher?.close();
|
|
2033
|
+
resolve();
|
|
2034
|
+
});
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// src/cli/subcommand/handlers/logCommand.ts
|
|
2039
|
+
var VALID_LEVELS = ["debug", "info", "error"];
|
|
2040
|
+
async function handleLog(args) {
|
|
2041
|
+
if (args.length === 0) {
|
|
2042
|
+
printLogHelp();
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
const verb = args[0];
|
|
2046
|
+
const rest = args.slice(1);
|
|
2047
|
+
switch (verb) {
|
|
2048
|
+
case "level":
|
|
2049
|
+
await handleLogLevel(rest);
|
|
2050
|
+
break;
|
|
2051
|
+
case "path":
|
|
2052
|
+
await handleLogPath(rest);
|
|
2053
|
+
break;
|
|
2054
|
+
default:
|
|
2055
|
+
printer_default.error(`Unknown log subcommand: ${verb}`);
|
|
2056
|
+
printLogHelp();
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
async function handleLogLevel(args) {
|
|
2060
|
+
const client = new TunnelClient();
|
|
2061
|
+
try {
|
|
2062
|
+
await client.ensureDaemon();
|
|
2063
|
+
} catch (err) {
|
|
2064
|
+
printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
try {
|
|
2068
|
+
if (args.length === 0) {
|
|
2069
|
+
const level2 = await client.getLogLevel();
|
|
2070
|
+
printer_default.print(`Log level: ${level2}`);
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
const level = args[0].toLowerCase();
|
|
2074
|
+
if (!VALID_LEVELS.includes(level)) {
|
|
2075
|
+
printer_default.error(`Invalid log level: "${level}". Must be one of: ${VALID_LEVELS.join(", ")}`);
|
|
2076
|
+
client.close();
|
|
2077
|
+
process.exit(1);
|
|
2078
|
+
}
|
|
2079
|
+
await client.setLogLevel(level);
|
|
2080
|
+
printer_default.success(`Log level set to "${level}". Affects daemon JS logs and new tunnels. Run \`pinggy restart <name>\` to apply to a running tunnel.`);
|
|
2081
|
+
} catch (err) {
|
|
2082
|
+
printer_default.error(`Failed to update log level: ${errorMessage(err)}`);
|
|
2083
|
+
} finally {
|
|
2084
|
+
client.close();
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
async function handleLogPath(args) {
|
|
2088
|
+
const client = new TunnelClient();
|
|
2089
|
+
try {
|
|
2090
|
+
await client.ensureDaemon();
|
|
2091
|
+
} catch (err) {
|
|
2092
|
+
printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
try {
|
|
2096
|
+
if (args.length === 0) {
|
|
2097
|
+
const paths = await client.getLogPaths();
|
|
2098
|
+
printer_default.print(paths.daemon);
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
const arg = args[0];
|
|
2102
|
+
const result = await client.resolveLogPath(arg);
|
|
2103
|
+
switch (result.status) {
|
|
2104
|
+
case "running":
|
|
2105
|
+
case "historical":
|
|
2106
|
+
printer_default.print(result.path);
|
|
2107
|
+
break;
|
|
2108
|
+
case "config-only":
|
|
2109
|
+
printer_default.error(`Tunnel "${arg}" is saved but has not been started yet. No logs available.`);
|
|
2110
|
+
client.close();
|
|
2111
|
+
process.exit(1);
|
|
2112
|
+
break;
|
|
2113
|
+
case "not-found":
|
|
2114
|
+
printer_default.error(`No tunnel or log file matching "${arg}".`);
|
|
2115
|
+
client.close();
|
|
2116
|
+
process.exit(1);
|
|
2117
|
+
break;
|
|
2118
|
+
}
|
|
2119
|
+
} catch (err) {
|
|
2120
|
+
printer_default.error(`Failed to resolve log path: ${errorMessage(err)}`);
|
|
2121
|
+
} finally {
|
|
2122
|
+
client.close();
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// src/cli/subcommand/handlers/restartCommand.ts
|
|
2127
|
+
async function handleRestart(args) {
|
|
2128
|
+
if (args.length === 0 || args[0].startsWith("-")) {
|
|
2129
|
+
printer_default.error("Tunnel name or ID is required. Usage: pinggy restart <name|id>");
|
|
2130
|
+
process.exit(1);
|
|
2131
|
+
}
|
|
2132
|
+
const nameOrId = args[0];
|
|
2133
|
+
const client = new TunnelClient();
|
|
2134
|
+
try {
|
|
2135
|
+
await client.ensureDaemon();
|
|
2136
|
+
} catch (err) {
|
|
2137
|
+
printer_default.error(`Cannot connect to daemon: ${errorMessage(err)}`);
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
try {
|
|
2141
|
+
const tunnels = await client.handleListV2();
|
|
2142
|
+
if (isErrorResponse(tunnels)) {
|
|
2143
|
+
printer_default.error(`Failed to list tunnels: ${tunnels.message}`);
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
const match = findTunnel(tunnels, nameOrId);
|
|
2147
|
+
if (!match) {
|
|
2148
|
+
printer_default.error(`No running tunnel matching "${nameOrId}". Use: pinggy ps`);
|
|
2149
|
+
process.exit(1);
|
|
2150
|
+
}
|
|
2151
|
+
const result = await client.handleRestart(match.tunnelid);
|
|
2152
|
+
if (isErrorResponse(result)) {
|
|
2153
|
+
printer_default.error(`Failed to restart tunnel: ${result.message}`);
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
const name = match.tunnelconfig.name || match.tunnelid.slice(0, 12);
|
|
2157
|
+
printer_default.success(`Tunnel "${name}" is restarting.`);
|
|
2158
|
+
} catch (err) {
|
|
2159
|
+
printer_default.error(`Failed to restart tunnel: ${errorMessage(err)}`);
|
|
2160
|
+
} finally {
|
|
2161
|
+
client.close();
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// src/cli/subcommand/subcommands.ts
|
|
2166
|
+
var SUBCOMMAND_SET = new Set(SUBCOMMANDS);
|
|
2167
|
+
function isSubcommand(rawArgs) {
|
|
2168
|
+
return rawArgs.length > 0 && SUBCOMMAND_SET.has(rawArgs[0]);
|
|
2169
|
+
}
|
|
2170
|
+
async function handleSubcommand(rawArgs) {
|
|
2171
|
+
const sub = rawArgs[0];
|
|
2172
|
+
const rest = rawArgs.slice(1);
|
|
2173
|
+
switch (sub) {
|
|
2174
|
+
case Subcommand.Config:
|
|
2175
|
+
handleConfig(rest);
|
|
2176
|
+
return;
|
|
2177
|
+
case Subcommand.Start:
|
|
2178
|
+
await handleStart(rest);
|
|
2179
|
+
return;
|
|
2180
|
+
case Subcommand.Stop:
|
|
2181
|
+
await handleStop(rest);
|
|
2182
|
+
return;
|
|
2183
|
+
case Subcommand.Ps:
|
|
2184
|
+
await handlePs();
|
|
2185
|
+
return;
|
|
2186
|
+
case Subcommand.Attach:
|
|
2187
|
+
await handleAttach(rest);
|
|
2188
|
+
return;
|
|
2189
|
+
case Subcommand.Daemon:
|
|
2190
|
+
case Subcommand.DaemonAlias:
|
|
2191
|
+
await handleDaemon(rest);
|
|
2192
|
+
return;
|
|
2193
|
+
case Subcommand.Logs: {
|
|
2194
|
+
const follow = rest.includes("-f");
|
|
2195
|
+
const nonFlagArgs = rest.filter((a) => a !== "-f");
|
|
2196
|
+
await handleLogs(nonFlagArgs, follow);
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
case Subcommand.Log:
|
|
2200
|
+
await handleLog(rest);
|
|
2201
|
+
return;
|
|
2202
|
+
case Subcommand.Restart:
|
|
2203
|
+
await handleRestart(rest);
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
function handleConfig(args) {
|
|
2208
|
+
if (args.length === 0) {
|
|
2209
|
+
printConfigHelp();
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
const verb = args[0];
|
|
2213
|
+
const rest = args.slice(1);
|
|
2214
|
+
switch (verb) {
|
|
2215
|
+
case ConfigVerb.List:
|
|
2216
|
+
case ConfigVerb.Ls:
|
|
2217
|
+
printConfigList();
|
|
2218
|
+
return;
|
|
2219
|
+
case ConfigVerb.Show: {
|
|
2220
|
+
const names = requireNames(rest, "config show");
|
|
2221
|
+
for (const name of names) {
|
|
2222
|
+
const saved2 = resolveConfig(name);
|
|
2223
|
+
if (saved2) printConfigDetail(saved2);
|
|
2224
|
+
}
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
case ConfigVerb.Save: {
|
|
2228
|
+
const name = requireName(rest, "config save");
|
|
2229
|
+
handleConfigSave(name, rest.slice(1));
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
case ConfigVerb.Delete: {
|
|
2233
|
+
const names = requireNames(rest, "config delete");
|
|
2234
|
+
for (const name of names) {
|
|
2235
|
+
const deletedName = deleteConfig(name);
|
|
2236
|
+
if (deletedName) {
|
|
2237
|
+
printer_default.success(`Config "${deletedName}" deleted.`);
|
|
2238
|
+
} else {
|
|
2239
|
+
printer_default.error(`No config found matching "${name}". Use: pinggy config list`);
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
case ConfigVerb.Update: {
|
|
2245
|
+
const name = requireName(rest, "config update");
|
|
2246
|
+
handleConfigUpdate(name, rest.slice(1));
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
case ConfigVerb.Auto: {
|
|
2250
|
+
const names = requireNames(rest, "config auto");
|
|
2251
|
+
for (const name of names) {
|
|
2252
|
+
const updated = updateConfigAutoStart(name, true);
|
|
2253
|
+
if (updated) {
|
|
2254
|
+
printer_default.success(`Config "${updated.name}" auto-start set to on.`);
|
|
2255
|
+
} else {
|
|
2256
|
+
printer_default.error(`No config found matching "${name}". Use: pinggy config list`);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
case ConfigVerb.Noauto: {
|
|
2262
|
+
const names = requireNames(rest, "config noauto");
|
|
2263
|
+
for (const name of names) {
|
|
2264
|
+
const updated = updateConfigAutoStart(name, false);
|
|
2265
|
+
if (updated) {
|
|
2266
|
+
printer_default.success(`Config "${updated.name}" auto-start set to off.`);
|
|
2267
|
+
} else {
|
|
2268
|
+
printer_default.error(`No config found matching "${name}". Use: pinggy config list`);
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
default:
|
|
2274
|
+
const saved = resolveConfig(verb);
|
|
2275
|
+
if (saved) printConfigDetail(saved);
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
function handleConfigSave(name, remainingArgs) {
|
|
2280
|
+
const nameErr = validateName(name);
|
|
2281
|
+
if (nameErr) {
|
|
2282
|
+
printer_default.error(nameErr.message);
|
|
2283
|
+
process.exit(1);
|
|
2284
|
+
}
|
|
2285
|
+
const { values, positionals } = parseCliArgs(cliOptions, remainingArgs);
|
|
2286
|
+
const autoStart = !!values.auto;
|
|
2287
|
+
logger.debug("Building config for save", { name, values, positionals });
|
|
2288
|
+
const finalConfig = buildFinalConfig(values, positionals);
|
|
2289
|
+
finalConfig.name = name;
|
|
2290
|
+
saveConfig(name, finalConfig.configId, finalConfig, autoStart);
|
|
2291
|
+
printer_default.success(`Config "${name}" saved.`);
|
|
2292
|
+
}
|
|
2293
|
+
function handleConfigUpdate(nameOrId, remainingArgs) {
|
|
2294
|
+
const saved = resolveConfig(nameOrId);
|
|
2295
|
+
if (!saved) return;
|
|
2296
|
+
const { values, positionals } = parseCliArgs(cliOptions, remainingArgs);
|
|
2297
|
+
logger.debug("Building updated config", { nameOrId, values, positionals });
|
|
2298
|
+
const updatedConfig = buildFinalConfig(values, positionals, saved.tunnelConfig);
|
|
2299
|
+
updatedConfig.name = saved.name;
|
|
2300
|
+
const result = updateTunnelConfig(nameOrId, updatedConfig);
|
|
2301
|
+
if (result) {
|
|
2302
|
+
printer_default.success(`Config "${result.name}" updated.`);
|
|
2303
|
+
printConfigDetail(result);
|
|
2304
|
+
} else {
|
|
2305
|
+
printer_default.error(`Failed to update config "${nameOrId}".`);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
async function handleStart(args) {
|
|
2309
|
+
const names = [];
|
|
2310
|
+
let i = 0;
|
|
2311
|
+
while (i < args.length && !args[i].startsWith("-")) {
|
|
2312
|
+
names.push(args[i]);
|
|
2313
|
+
i++;
|
|
2314
|
+
}
|
|
2315
|
+
const flagArgs = args.slice(i);
|
|
2316
|
+
const { values, positionals } = parseCliArgs(cliOptions, flagArgs);
|
|
2317
|
+
configureLogger(values);
|
|
2318
|
+
if (values.all) {
|
|
2319
|
+
await initRemoteManagementBackground(values);
|
|
2320
|
+
await startAutoStartTunnels();
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
if (names.length === 0) {
|
|
2324
|
+
printStartHelp();
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
const resolved = [];
|
|
2328
|
+
for (const name of names) {
|
|
2329
|
+
const saved = resolveConfig(name);
|
|
2330
|
+
if (!saved) return;
|
|
2331
|
+
resolved.push(saved);
|
|
2332
|
+
}
|
|
2333
|
+
if (resolved.length > 1 && flagArgs.length > 0) {
|
|
2334
|
+
printer_default.error("Runtime overrides (-l, --type, etc.) can only be used when starting a single tunnel.");
|
|
2335
|
+
printer_default.print(" Start one tunnel: pinggy start my-tunnel -l 4000");
|
|
2336
|
+
printer_default.print(" Or update first: pinggy config update my-tunnel -l 4000");
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
await initRemoteManagementBackground(values);
|
|
2340
|
+
if (values.b) {
|
|
2341
|
+
await startBackgroundTunnels(resolved, values, positionals);
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
if (resolved.length === 1) {
|
|
2345
|
+
const saved = resolved[0];
|
|
2346
|
+
logger.debug("Building config with overrides", { name: saved.name });
|
|
2347
|
+
const finalConfig = buildFinalConfig(values, positionals, saved.tunnelConfig);
|
|
2348
|
+
await startForegroundViaDaemon(finalConfig);
|
|
2349
|
+
} else {
|
|
2350
|
+
await startMultipleForegroundViaDaemon(resolved, values, positionals);
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
function resolveConfig(nameOrId) {
|
|
2354
|
+
const saved = findConfig(nameOrId);
|
|
2355
|
+
if (!saved) {
|
|
2356
|
+
printer_default.error(`No config found matching "${nameOrId}". Use: pinggy config list`);
|
|
2357
|
+
return null;
|
|
2358
|
+
}
|
|
2359
|
+
return saved;
|
|
2360
|
+
}
|
|
2361
|
+
function requireName(args, command) {
|
|
2362
|
+
if (args.length === 0 || args[0].startsWith("-")) {
|
|
2363
|
+
printer_default.error(`Tunnel name is required. Usage: pinggy ${command} <name>`);
|
|
2364
|
+
process.exit(1);
|
|
2365
|
+
}
|
|
2366
|
+
return args[0];
|
|
2367
|
+
}
|
|
2368
|
+
function requireNames(args, command) {
|
|
2369
|
+
const names = [];
|
|
2370
|
+
for (const arg of args) {
|
|
2371
|
+
if (arg.startsWith("-")) break;
|
|
2372
|
+
names.push(arg);
|
|
2373
|
+
}
|
|
2374
|
+
if (names.length === 0) {
|
|
2375
|
+
printer_default.error(`At least one tunnel name is required. Usage: pinggy ${command} <name> [name2 ...]`);
|
|
2376
|
+
process.exit(1);
|
|
2377
|
+
}
|
|
2378
|
+
return names;
|
|
2379
|
+
}
|
|
2380
|
+
async function initRemoteManagementBackground(values) {
|
|
2381
|
+
const rmToken = values["remote-management"];
|
|
2382
|
+
if (typeof rmToken === "string" && rmToken.trim().length > 0) {
|
|
2383
|
+
const manageHost = values["manage"];
|
|
2384
|
+
try {
|
|
2385
|
+
const handler = await TunnelClient.forRemoteManagement();
|
|
2386
|
+
handler.onDaemonLost((reason, detail) => {
|
|
2387
|
+
printer_default.error(daemonLostMessage(reason, detail));
|
|
2388
|
+
setImmediate(() => process.exit(3));
|
|
2389
|
+
});
|
|
2390
|
+
await startRemoteManagement({
|
|
2391
|
+
apiKey: rmToken,
|
|
2392
|
+
serverUrl: buildRemoteManagementWsUrl(manageHost)
|
|
2393
|
+
}, handler);
|
|
2394
|
+
} catch (e) {
|
|
2395
|
+
logger.error("Failed to initiate remote management:", e);
|
|
2396
|
+
printer_default.fatal(e);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
// src/daemon/lifecycle/daemonChild.ts
|
|
2402
|
+
import fs6 from "fs";
|
|
2403
|
+
|
|
2404
|
+
// src/daemon/ipc/ipcServer.ts
|
|
2405
|
+
import http from "http";
|
|
2406
|
+
import fs5 from "fs";
|
|
2407
|
+
import path4 from "path";
|
|
2408
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
2409
|
+
|
|
2410
|
+
// src/daemon/ws/wsProtocol.ts
|
|
2411
|
+
function createTunnelEvent(tunnelId, event, payload) {
|
|
2412
|
+
return { type: "tunnel_event", tunnelId, event, payload };
|
|
2413
|
+
}
|
|
2414
|
+
function parseClientMessage(raw) {
|
|
2415
|
+
try {
|
|
2416
|
+
const msg = JSON.parse(raw);
|
|
2417
|
+
if (msg.type === "subscribe" && msg.tunnelId) {
|
|
2418
|
+
return {
|
|
2419
|
+
type: "subscribe",
|
|
2420
|
+
tunnelId: msg.tunnelId,
|
|
2421
|
+
mode: msg.mode === SessionMode.Detached ? SessionMode.Detached : SessionMode.Foreground
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
if (msg.type === "unsubscribe" && msg.tunnelId) {
|
|
2425
|
+
return { type: "unsubscribe", tunnelId: msg.tunnelId };
|
|
2426
|
+
}
|
|
2427
|
+
return null;
|
|
2428
|
+
} catch {
|
|
2429
|
+
return null;
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// src/daemon/lifecycle/stateStore.ts
|
|
2434
|
+
import fs4 from "fs";
|
|
2435
|
+
import path3 from "path";
|
|
2436
|
+
var STATE_FILENAME = "daemon-state.json";
|
|
2437
|
+
function getStatePath() {
|
|
2438
|
+
return path3.join(getPinggyConfigDir(), STATE_FILENAME);
|
|
2439
|
+
}
|
|
2440
|
+
function loadDaemonState() {
|
|
2441
|
+
const statePath = getStatePath();
|
|
2442
|
+
try {
|
|
2443
|
+
if (!fs4.existsSync(statePath)) return null;
|
|
2444
|
+
const raw = fs4.readFileSync(statePath, "utf-8");
|
|
2445
|
+
const state = JSON.parse(raw);
|
|
2446
|
+
if (!Array.isArray(state.tunnels)) return null;
|
|
2447
|
+
return state;
|
|
2448
|
+
} catch {
|
|
2449
|
+
return null;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
function persistDaemonState(state) {
|
|
2453
|
+
ensurePinggyConfigDir();
|
|
2454
|
+
const statePath = getStatePath();
|
|
2455
|
+
const tmpPath = statePath + ".tmp";
|
|
2456
|
+
try {
|
|
2457
|
+
state.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
|
|
2458
|
+
fs4.writeFileSync(tmpPath, JSON.stringify(state, null, 2), "utf-8");
|
|
2459
|
+
fs4.renameSync(tmpPath, statePath);
|
|
2460
|
+
} catch (err) {
|
|
2461
|
+
logger.error("Failed to persist daemon state", { error: errorMessage(err) });
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
function clearDaemonState() {
|
|
2465
|
+
const statePath = getStatePath();
|
|
2466
|
+
try {
|
|
2467
|
+
if (fs4.existsSync(statePath)) {
|
|
2468
|
+
fs4.unlinkSync(statePath);
|
|
2469
|
+
}
|
|
2470
|
+
} catch {
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
function addTunnelToState(state, tunnel) {
|
|
2474
|
+
state.tunnels = state.tunnels.filter((t) => t.tunnelId !== tunnel.tunnelId);
|
|
2475
|
+
state.tunnels.push(tunnel);
|
|
2476
|
+
}
|
|
2477
|
+
function removeTunnelFromState(state, tunnelId) {
|
|
2478
|
+
state.tunnels = state.tunnels.filter((t) => t.tunnelId !== tunnelId);
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
// src/daemon/ipc/ipcServer.ts
|
|
2482
|
+
var VALID_ORIGINS = ["app", "cli", "remote"];
|
|
2483
|
+
function parseOrigin(req) {
|
|
2484
|
+
const raw = req.headers["x-pinggy-origin"];
|
|
2485
|
+
const v = Array.isArray(raw) ? raw[0] : raw;
|
|
2486
|
+
return v && VALID_ORIGINS.includes(v) ? v : "cli";
|
|
2487
|
+
}
|
|
2488
|
+
function parseBody(method, body) {
|
|
2489
|
+
if (method === "GET") return void 0;
|
|
2490
|
+
if (!body) return {};
|
|
2491
|
+
return JSON.parse(body);
|
|
2492
|
+
}
|
|
2493
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2494
|
+
function parseTunnelLogFilename(filename) {
|
|
2495
|
+
const base = filename.replace(/\.log$/, "");
|
|
2496
|
+
const parts = base.split("__");
|
|
2497
|
+
if (parts.length < 2) return null;
|
|
2498
|
+
const origin = parts[0];
|
|
2499
|
+
if (!VALID_ORIGINS.includes(origin)) return null;
|
|
2500
|
+
if (parts.length === 2) {
|
|
2501
|
+
const second = parts[1];
|
|
2502
|
+
if (UUID_RE.test(second)) return { origin, tunnelId: second };
|
|
2503
|
+
return { origin, name: second };
|
|
2504
|
+
}
|
|
2505
|
+
const tunnelId = parts[parts.length - 1];
|
|
2506
|
+
const name = parts.slice(1, -1).join("__");
|
|
2507
|
+
return { origin, name, tunnelId };
|
|
2508
|
+
}
|
|
2509
|
+
var IPCServer = class {
|
|
2510
|
+
constructor() {
|
|
2511
|
+
this.sessions = /* @__PURE__ */ new Map();
|
|
2512
|
+
this.sessionCounter = 0;
|
|
2513
|
+
this.onSessionDisconnect = null;
|
|
2514
|
+
this.sessionTracker = null;
|
|
2515
|
+
this.ops = new TunnelOperations();
|
|
2516
|
+
this.startedAt = Date.now();
|
|
2517
|
+
this.server = http.createServer(this.handleRequest.bind(this));
|
|
2518
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
2519
|
+
this.routes = this.buildRoutes();
|
|
2520
|
+
this.setupWebSocket();
|
|
2521
|
+
}
|
|
2522
|
+
/**
|
|
2523
|
+
* Set callback for when a WS session disconnects.
|
|
2524
|
+
* Used by SessionTracker for orphan cleanup.
|
|
2525
|
+
*/
|
|
2526
|
+
setOnSessionDisconnect(cb) {
|
|
2527
|
+
this.onSessionDisconnect = cb;
|
|
2528
|
+
}
|
|
2529
|
+
setSessionTracker(st) {
|
|
2530
|
+
this.sessionTracker = st;
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* Get all active sessions (for SessionTracker inspection).
|
|
2534
|
+
*/
|
|
2535
|
+
getSessions() {
|
|
2536
|
+
return this.sessions;
|
|
2537
|
+
}
|
|
2538
|
+
buildRoutes() {
|
|
2539
|
+
return {
|
|
2540
|
+
[Route.Ping]: () => ({
|
|
2541
|
+
status: "ok",
|
|
2542
|
+
pid: process.pid,
|
|
2543
|
+
uptime: Math.floor((Date.now() - this.startedAt) / 1e3)
|
|
2544
|
+
}),
|
|
2545
|
+
[Route.ListTunnels]: async () => {
|
|
2546
|
+
const res = await this.ops.handleListV2();
|
|
2547
|
+
if (isErrorResponse(res)) return res;
|
|
2548
|
+
return res.map((t) => ({
|
|
2549
|
+
...t,
|
|
2550
|
+
mode: this.sessionTracker?.getOwnership(t.tunnelid)?.mode
|
|
2551
|
+
}));
|
|
2552
|
+
},
|
|
2553
|
+
[Route.ListTunnelsV1]: async () => {
|
|
2554
|
+
return await this.ops.handleList();
|
|
2555
|
+
},
|
|
2556
|
+
[Route.StartTunnel]: async (req, ctx) => {
|
|
2557
|
+
if (!req.name) throw new Error("Missing 'name' field");
|
|
2558
|
+
const saved = findConfig(req.name);
|
|
2559
|
+
if (!saved) throw new Error(`No config found matching "${req.name}"`);
|
|
2560
|
+
const config = {
|
|
2561
|
+
...saved.tunnelConfig,
|
|
2562
|
+
configId: saved.configId,
|
|
2563
|
+
name: saved.name
|
|
2564
|
+
};
|
|
2565
|
+
const result = await this.ops.handleStartV2(config, false, ctx.origin);
|
|
2566
|
+
if (!isErrorResponse(result)) {
|
|
2567
|
+
trackIPCTunnelStart(result.tunnelid, ctx.origin, req.mode);
|
|
2568
|
+
}
|
|
2569
|
+
return result;
|
|
2570
|
+
},
|
|
2571
|
+
[Route.StartTunnelConfig]: async (req, ctx) => {
|
|
2572
|
+
if (!req?.config) throw new Error("Missing 'config' field");
|
|
2573
|
+
const result = await this.ops.handleStartV2(req.config, req.noWait, ctx.origin);
|
|
2574
|
+
if (!isErrorResponse(result)) {
|
|
2575
|
+
trackIPCTunnelStart(result.tunnelid, ctx.origin, req.mode);
|
|
2576
|
+
}
|
|
2577
|
+
return result;
|
|
2578
|
+
},
|
|
2579
|
+
[Route.StartTunnelV1]: async (req, ctx) => {
|
|
2580
|
+
if (!req.config) throw new Error("Missing 'config' field");
|
|
2581
|
+
const result = await this.ops.handleStart(req.config, req.noWait, ctx.origin);
|
|
2582
|
+
if (!isErrorResponse(result)) {
|
|
2583
|
+
trackIPCTunnelStart(result.tunnelid, ctx.origin, req.mode);
|
|
2584
|
+
}
|
|
2585
|
+
return result;
|
|
2586
|
+
},
|
|
2587
|
+
[Route.StopTunnel]: async (req) => {
|
|
2588
|
+
if (!req.tunnelid) throw new Error("Missing 'tunnelid' field");
|
|
2589
|
+
const result = await this.ops.handleStop(req.tunnelid);
|
|
2590
|
+
this.sessionTracker?.removeTunnel(req.tunnelid);
|
|
2591
|
+
if (!isErrorResponse(result)) {
|
|
2592
|
+
trackTunnelStop(req.tunnelid);
|
|
2593
|
+
}
|
|
2594
|
+
return result;
|
|
2595
|
+
},
|
|
2596
|
+
[Route.RestartTunnel]: async (req) => {
|
|
2597
|
+
if (!req.tunnelid) throw new Error("Missing 'tunnelid' field");
|
|
2598
|
+
return await this.ops.handleRestart(req.tunnelid);
|
|
2599
|
+
},
|
|
2600
|
+
[Route.UpdateConfig]: async (req) => {
|
|
2601
|
+
if (!req.config) throw new Error("Missing 'config' field");
|
|
2602
|
+
return await this.ops.handleUpdateConfig(req.config, req.noWait);
|
|
2603
|
+
},
|
|
2604
|
+
[Route.UpdateConfigV2]: async (req) => {
|
|
2605
|
+
if (!req.config) throw new Error("Missing 'config' field");
|
|
2606
|
+
return await this.ops.handleUpdateConfigV2(req.config, req.noWait);
|
|
2607
|
+
},
|
|
2608
|
+
[Route.RemoveStopped]: (req) => {
|
|
2609
|
+
if (req.tunnelid) return { result: this.ops.handleRemoveStoppedTunnelByTunnelId(req.tunnelid) };
|
|
2610
|
+
if (req.configId) return { result: this.ops.handleRemoveStoppedTunnelByConfigId(req.configId) };
|
|
2611
|
+
throw new Error("Missing 'tunnelid' or 'configId' field");
|
|
2612
|
+
},
|
|
2613
|
+
[Route.GetLogLevel]: () => {
|
|
2614
|
+
return { level: getLogLevel() };
|
|
2615
|
+
},
|
|
2616
|
+
[Route.SetLogLevel]: (req) => {
|
|
2617
|
+
if (!["debug", "info", "error"].includes(req.level)) {
|
|
2618
|
+
throw new Error(`Invalid log level: ${req.level}. Must be debug, info, or error`);
|
|
2619
|
+
}
|
|
2620
|
+
setLogLevel(req.level);
|
|
2621
|
+
return { level: req.level, appliedTo: "daemon-js+active-workers-js+future-workers" };
|
|
2622
|
+
},
|
|
2623
|
+
[Route.GetTunnelLogging]: () => {
|
|
2624
|
+
return { enabled: isTunnelLoggingEnabled() };
|
|
2625
|
+
},
|
|
2626
|
+
[Route.SetTunnelLogging]: (req) => {
|
|
2627
|
+
if (typeof req.enabled !== "boolean") throw new Error("Missing 'enabled' boolean");
|
|
2628
|
+
setTunnelLoggingEnabled(req.enabled);
|
|
2629
|
+
return { enabled: isTunnelLoggingEnabled() };
|
|
2630
|
+
},
|
|
2631
|
+
[Route.GetLogPaths]: async () => {
|
|
2632
|
+
const logDir = getTunnelLogDir();
|
|
2633
|
+
const daemonPath = getDaemonLogPath();
|
|
2634
|
+
const tunnels = [];
|
|
2635
|
+
if (fs5.existsSync(logDir)) {
|
|
2636
|
+
const files = fs5.readdirSync(logDir).filter((f) => f.endsWith(".log") && !/\.log\.\d+$/.test(f));
|
|
2637
|
+
const manager = TunnelManager.getInstance();
|
|
2638
|
+
const activeIds = manager.getActiveTunnelIds();
|
|
2639
|
+
const activeNames = /* @__PURE__ */ new Set();
|
|
2640
|
+
for (const t of await manager.getAllTunnels()) {
|
|
2641
|
+
if (!activeIds.has(t.tunnelid)) continue;
|
|
2642
|
+
const n = t.tunnelName ?? t.tunnelConfig?.name;
|
|
2643
|
+
if (n) activeNames.add(n);
|
|
2644
|
+
}
|
|
2645
|
+
for (const file of files) {
|
|
2646
|
+
const filePath = path4.join(logDir, file);
|
|
2647
|
+
const stat = fs5.statSync(filePath);
|
|
2648
|
+
const parsed = parseTunnelLogFilename(file);
|
|
2649
|
+
if (!parsed) continue;
|
|
2650
|
+
const running = parsed.tunnelId ? activeIds.has(parsed.tunnelId) : parsed.name ? activeNames.has(parsed.name) : false;
|
|
2651
|
+
tunnels.push({
|
|
2652
|
+
tunnelId: parsed.tunnelId,
|
|
2653
|
+
name: parsed.name,
|
|
2654
|
+
origin: parsed.origin,
|
|
2655
|
+
path: filePath,
|
|
2656
|
+
mtime: stat.mtimeMs,
|
|
2657
|
+
running
|
|
2658
|
+
});
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
return { daemon: daemonPath, tunnels };
|
|
2662
|
+
},
|
|
2663
|
+
[Route.Shutdown]: () => {
|
|
2664
|
+
logger.info("Daemon shutdown requested via IPC");
|
|
2665
|
+
const errors = [];
|
|
2666
|
+
const step = (label, fn) => {
|
|
2667
|
+
try {
|
|
2668
|
+
fn();
|
|
2669
|
+
} catch (e) {
|
|
2670
|
+
const msg = errorMessage(e);
|
|
2671
|
+
errors.push(`${label}: ${msg}`);
|
|
2672
|
+
logger.error(`Shutdown step "${label}" failed`, { error: msg });
|
|
2673
|
+
}
|
|
2674
|
+
};
|
|
2675
|
+
step("removeDaemonInfo", removeDaemonInfo);
|
|
2676
|
+
step("clearDaemonState", clearDaemonState);
|
|
2677
|
+
step("stopAllTunnels", () => TunnelManager.getInstance().stopAllTunnels());
|
|
2678
|
+
setTimeout(() => process.exit(0), 200);
|
|
2679
|
+
return { status: "shutting_down", errors };
|
|
2680
|
+
}
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
async handleRequest(req, res) {
|
|
2684
|
+
const method = req.method ?? "GET";
|
|
2685
|
+
const url = req.url ?? "/";
|
|
2686
|
+
const ctx = { origin: parseOrigin(req) };
|
|
2687
|
+
const pathOnly = url.split("?")[0];
|
|
2688
|
+
const routeKey = `${method} ${pathOnly}`;
|
|
2689
|
+
const handler = this.routes[routeKey];
|
|
2690
|
+
if (handler) {
|
|
2691
|
+
try {
|
|
2692
|
+
const body = await this.readBody(req);
|
|
2693
|
+
const parsed = parseBody(method, body);
|
|
2694
|
+
const result = await handler(parsed, ctx);
|
|
2695
|
+
this.sendJson(res, 200, result);
|
|
2696
|
+
} catch (err) {
|
|
2697
|
+
const msg = errorMessage(err);
|
|
2698
|
+
logger.error("IPC handler error", { routeKey, error: msg });
|
|
2699
|
+
this.sendJson(res, 500, { error: msg });
|
|
2700
|
+
}
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
const paramMatch = this.matchParameterizedRoute(method, url);
|
|
2704
|
+
if (paramMatch) {
|
|
2705
|
+
try {
|
|
2706
|
+
await this.readBody(req);
|
|
2707
|
+
const response = await paramMatch.invoke();
|
|
2708
|
+
this.sendJson(res, 200, response);
|
|
2709
|
+
} catch (err) {
|
|
2710
|
+
const msg = errorMessage(err);
|
|
2711
|
+
logger.error("IPC handler error", { route: `${method} ${url}`, error: msg });
|
|
2712
|
+
this.sendJson(res, 500, { error: msg });
|
|
2713
|
+
}
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
this.sendJson(res, 404, { error: "Not found", path: url });
|
|
2717
|
+
}
|
|
2718
|
+
matchParameterizedRoute(method, url) {
|
|
2719
|
+
if (method === "GET") {
|
|
2720
|
+
const statsMatch = url.match(/^\/tunnels\/([^/?]+)\/stats(?:\?.*)?$/);
|
|
2721
|
+
if (statsMatch) {
|
|
2722
|
+
const tunnelId = statsMatch[1];
|
|
2723
|
+
const handler = async ({ tunnelId: id }) => this.ops.handleGetTunnelStats(id);
|
|
2724
|
+
return { invoke: () => handler({ tunnelId }) };
|
|
2725
|
+
}
|
|
2726
|
+
const match = url.match(/^\/tunnels\/([^/?]+)(?:\?.*)?$/);
|
|
2727
|
+
if (match) {
|
|
2728
|
+
const tunnelId = match[1];
|
|
2729
|
+
const handler = async ({ tunnelId: id }) => this.ops.handleGet(id);
|
|
2730
|
+
return { invoke: () => handler({ tunnelId }) };
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
if (method === "GET" && url.startsWith("/logs/resolve")) {
|
|
2734
|
+
const urlObj = new URL(url, "http://localhost");
|
|
2735
|
+
const q = urlObj.searchParams.get("q") || "";
|
|
2736
|
+
const handler = async ({ q: query }) => this.resolveLogPath(query);
|
|
2737
|
+
return { invoke: () => handler({ q }) };
|
|
2738
|
+
}
|
|
2739
|
+
return null;
|
|
2740
|
+
}
|
|
2741
|
+
async resolveLogPath(q) {
|
|
2742
|
+
if (!q) return { status: "not-found" };
|
|
2743
|
+
const logDir = getTunnelLogDir();
|
|
2744
|
+
const manager = TunnelManager.getInstance();
|
|
2745
|
+
const activeIds = manager.getActiveTunnelIds();
|
|
2746
|
+
for (const t of await manager.getAllTunnels()) {
|
|
2747
|
+
if (!activeIds.has(t.tunnelid)) continue;
|
|
2748
|
+
const name = t.tunnelName ?? t.tunnelConfig?.name;
|
|
2749
|
+
if (name === q || t.tunnelid === q || t.tunnelid.startsWith(q)) {
|
|
2750
|
+
const origin = "cli";
|
|
2751
|
+
const logPath = getTunnelLogPath(t.tunnelid, origin, name);
|
|
2752
|
+
return { status: "running", path: logPath, tunnelId: t.tunnelid, name, origin, running: true };
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
if (fs5.existsSync(logDir)) {
|
|
2756
|
+
const files = fs5.readdirSync(logDir).filter((f) => f.endsWith(".log") && !/\.log\.\d+$/.test(f));
|
|
2757
|
+
const matches = [];
|
|
2758
|
+
for (const file of files) {
|
|
2759
|
+
const parsed = parseTunnelLogFilename(file);
|
|
2760
|
+
if (!parsed) continue;
|
|
2761
|
+
const nameMatch = parsed.name !== void 0 && parsed.name === q;
|
|
2762
|
+
const idMatch = parsed.tunnelId !== void 0 && (parsed.tunnelId === q || parsed.tunnelId.startsWith(q));
|
|
2763
|
+
if (nameMatch || idMatch) {
|
|
2764
|
+
const filePath = path4.join(logDir, file);
|
|
2765
|
+
const stat = fs5.statSync(filePath);
|
|
2766
|
+
matches.push({ path: filePath, mtime: stat.mtimeMs, tunnelId: parsed.tunnelId, name: parsed.name, origin: parsed.origin });
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
if (matches.length > 0) {
|
|
2770
|
+
matches.sort((a, b) => b.mtime - a.mtime);
|
|
2771
|
+
const best = matches[0];
|
|
2772
|
+
return { status: "historical", path: best.path, tunnelId: best.tunnelId, name: best.name, origin: best.origin, running: false };
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
const saved = listSavedConfigs();
|
|
2776
|
+
const matchedConfig = saved.find((c) => c.name === q || c.configId === q || c.configId.startsWith(q));
|
|
2777
|
+
if (matchedConfig) {
|
|
2778
|
+
return { status: "config-only", name: matchedConfig.name, configId: matchedConfig.configId };
|
|
2779
|
+
}
|
|
2780
|
+
return { status: "not-found" };
|
|
2781
|
+
}
|
|
2782
|
+
setupWebSocket() {
|
|
2783
|
+
this.server.on("upgrade", (req, socket, head) => {
|
|
2784
|
+
if (req.url === "/ws") {
|
|
2785
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2786
|
+
this.wss.emit("connection", ws, req);
|
|
2787
|
+
});
|
|
2788
|
+
} else {
|
|
2789
|
+
socket.destroy();
|
|
2790
|
+
}
|
|
2791
|
+
});
|
|
2792
|
+
this.wss.on("connection", (ws) => {
|
|
2793
|
+
const session = this.createSession(ws);
|
|
2794
|
+
logger.info(`WS session connected: ${session.id}`);
|
|
2795
|
+
ws.on("message", (data) => {
|
|
2796
|
+
const raw = data.toString();
|
|
2797
|
+
const msg = parseClientMessage(raw);
|
|
2798
|
+
if (msg) {
|
|
2799
|
+
this.handleClientMessage(session, msg);
|
|
2800
|
+
}
|
|
2801
|
+
});
|
|
2802
|
+
ws.on("close", () => {
|
|
2803
|
+
logger.info(`WS session disconnected: ${session.id}`);
|
|
2804
|
+
this.cleanupSession(session);
|
|
2805
|
+
if (this.onSessionDisconnect) {
|
|
2806
|
+
this.onSessionDisconnect(session);
|
|
2807
|
+
}
|
|
2808
|
+
});
|
|
2809
|
+
ws.on("error", (err) => {
|
|
2810
|
+
logger.error(`WS session error: ${session.id}`, { error: err.message });
|
|
2811
|
+
});
|
|
2812
|
+
});
|
|
2813
|
+
}
|
|
2814
|
+
createSession(ws) {
|
|
2815
|
+
const id = `ws_${++this.sessionCounter}_${Date.now()}`;
|
|
2816
|
+
const session = {
|
|
2817
|
+
id,
|
|
2818
|
+
ws,
|
|
2819
|
+
subscriptions: /* @__PURE__ */ new Map(),
|
|
2820
|
+
listenerIds: /* @__PURE__ */ new Map()
|
|
2821
|
+
};
|
|
2822
|
+
this.sessions.set(id, session);
|
|
2823
|
+
return session;
|
|
2824
|
+
}
|
|
2825
|
+
handleClientMessage(session, msg) {
|
|
2826
|
+
switch (msg.type) {
|
|
2827
|
+
case "subscribe":
|
|
2828
|
+
this.handleSubscribe(session, msg.tunnelId, msg.mode).catch((err) => {
|
|
2829
|
+
logger.error("handleSubscribe failed", { sessionId: session.id, tunnelId: msg.tunnelId, error: err.message });
|
|
2830
|
+
});
|
|
2831
|
+
break;
|
|
2832
|
+
case "unsubscribe":
|
|
2833
|
+
this.handleUnsubscribe(session, msg.tunnelId);
|
|
2834
|
+
break;
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
async handleSubscribe(session, tunnelId, mode) {
|
|
2838
|
+
if (session.subscriptions.has(tunnelId)) {
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
const listenerIds = [];
|
|
2842
|
+
const manager = TunnelManager.getInstance();
|
|
2843
|
+
try {
|
|
2844
|
+
const [statsListenerId] = await manager.registerStatsListener(tunnelId, (_id, stats) => {
|
|
2845
|
+
this.sendEvent(session, tunnelId, "stats", { stats });
|
|
2846
|
+
});
|
|
2847
|
+
listenerIds.push(`stats:${statsListenerId}`);
|
|
2848
|
+
const disconnectId = await manager.registerDisconnectListener(tunnelId, (_id, error, messages) => {
|
|
2849
|
+
this.sendEvent(session, tunnelId, "disconnect", { error, messages });
|
|
2850
|
+
});
|
|
2851
|
+
listenerIds.push(`disconnect:${disconnectId}`);
|
|
2852
|
+
const stoppedId = await manager.registerStoppedListener(tunnelId, () => {
|
|
2853
|
+
this.sendEvent(session, tunnelId, "stopped", {});
|
|
2854
|
+
});
|
|
2855
|
+
listenerIds.push(`stopped:${stoppedId}`);
|
|
2856
|
+
const reconnectingId = await manager.registerReconnectingListener(tunnelId, (_id, retryCnt) => {
|
|
2857
|
+
this.sendEvent(session, tunnelId, "reconnecting", { retryCnt });
|
|
2858
|
+
});
|
|
2859
|
+
listenerIds.push(`reconnecting:${reconnectingId}`);
|
|
2860
|
+
const reconnectedId = await manager.registerReconnectionCompletedListener(tunnelId, (_id, urls) => {
|
|
2861
|
+
this.sendEvent(session, tunnelId, "reconnected", { urls });
|
|
2862
|
+
});
|
|
2863
|
+
listenerIds.push(`reconnected:${reconnectedId}`);
|
|
2864
|
+
const failedId = await manager.registerReconnectionFailedListener(tunnelId, (_id, retryCnt) => {
|
|
2865
|
+
this.sendEvent(session, tunnelId, "reconnection_failed", { retryCnt });
|
|
2866
|
+
});
|
|
2867
|
+
listenerIds.push(`failed:${failedId}`);
|
|
2868
|
+
const willReconnectId = await manager.registerWillReconnectListener(tunnelId, (_id, error, messages) => {
|
|
2869
|
+
this.sendEvent(session, tunnelId, "will_reconnect", { error, messages });
|
|
2870
|
+
});
|
|
2871
|
+
listenerIds.push(`will_reconnect:${willReconnectId}`);
|
|
2872
|
+
const workerErrorId = await manager.registerWorkerErrorListner(tunnelId, (_id, error) => {
|
|
2873
|
+
this.sendEvent(session, tunnelId, "worker_error", { message: error.message });
|
|
2874
|
+
});
|
|
2875
|
+
listenerIds.push(`worker_error:${workerErrorId}`);
|
|
2876
|
+
const startId = await manager.registerStartListener(tunnelId, (_id, urls) => {
|
|
2877
|
+
this.sendEvent(session, tunnelId, "url_ready", { urls });
|
|
2878
|
+
});
|
|
2879
|
+
listenerIds.push(`start:${startId}`);
|
|
2880
|
+
session.subscriptions.set(tunnelId, { tunnelId, mode });
|
|
2881
|
+
this.sessionTracker?.attach(tunnelId, session.id, mode);
|
|
2882
|
+
session.listenerIds.set(tunnelId, listenerIds);
|
|
2883
|
+
this.sendEvent(session, tunnelId, "subscribed", { tunnelId });
|
|
2884
|
+
} catch (err) {
|
|
2885
|
+
session.listenerIds.set(tunnelId, listenerIds);
|
|
2886
|
+
this.deregisterListeners(session, tunnelId);
|
|
2887
|
+
this.sendEvent(session, tunnelId, "error_response", { message: errorMessage(err) });
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
handleUnsubscribe(session, tunnelId) {
|
|
2891
|
+
this.deregisterListeners(session, tunnelId);
|
|
2892
|
+
session.subscriptions.delete(tunnelId);
|
|
2893
|
+
}
|
|
2894
|
+
deregisterListeners(session, tunnelId) {
|
|
2895
|
+
const ids = session.listenerIds.get(tunnelId);
|
|
2896
|
+
if (!ids) return;
|
|
2897
|
+
const manager = TunnelManager.getInstance();
|
|
2898
|
+
for (const entry of ids) {
|
|
2899
|
+
const [type, listenerId] = entry.split(":");
|
|
2900
|
+
try {
|
|
2901
|
+
switch (type) {
|
|
2902
|
+
case "stats":
|
|
2903
|
+
manager.deregisterStatsListener(tunnelId, listenerId);
|
|
2904
|
+
break;
|
|
2905
|
+
case "disconnect":
|
|
2906
|
+
manager.deregisterDisconnectListener(tunnelId, listenerId);
|
|
2907
|
+
break;
|
|
2908
|
+
case "stopped":
|
|
2909
|
+
manager.deregisterStoppedListener(tunnelId, listenerId);
|
|
2910
|
+
break;
|
|
2911
|
+
case "reconnecting":
|
|
2912
|
+
manager.deregisterReconnectingListener(tunnelId, listenerId);
|
|
2913
|
+
break;
|
|
2914
|
+
case "reconnected":
|
|
2915
|
+
manager.deregisterReconnectionCompletedListener(tunnelId, listenerId);
|
|
2916
|
+
break;
|
|
2917
|
+
case "failed":
|
|
2918
|
+
manager.deregisterReconnectionFailedListener(tunnelId, listenerId);
|
|
2919
|
+
break;
|
|
2920
|
+
case "will_reconnect":
|
|
2921
|
+
manager.deregisterWillReconnectListener(tunnelId, listenerId);
|
|
2922
|
+
break;
|
|
2923
|
+
case "worker_error":
|
|
2924
|
+
manager.deregisterWorkerErrorListener(tunnelId, listenerId);
|
|
2925
|
+
break;
|
|
2926
|
+
case "start":
|
|
2927
|
+
break;
|
|
2928
|
+
}
|
|
2929
|
+
} catch {
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
session.listenerIds.delete(tunnelId);
|
|
2933
|
+
}
|
|
2934
|
+
cleanupSession(session) {
|
|
2935
|
+
for (const tunnelId of session.subscriptions.keys()) {
|
|
2936
|
+
this.deregisterListeners(session, tunnelId);
|
|
2937
|
+
}
|
|
2938
|
+
session.subscriptions.clear();
|
|
2939
|
+
this.sessions.delete(session.id);
|
|
2940
|
+
}
|
|
2941
|
+
sendEvent(session, tunnelId, event, payload) {
|
|
2942
|
+
if (session.ws.readyState === WebSocket.OPEN) {
|
|
2943
|
+
const msg = createTunnelEvent(tunnelId, event, payload);
|
|
2944
|
+
session.ws.send(JSON.stringify(msg));
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
readBody(req) {
|
|
2948
|
+
return new Promise((resolve, reject) => {
|
|
2949
|
+
const chunks = [];
|
|
2950
|
+
let size = 0;
|
|
2951
|
+
const MAX_BODY = 1024 * 64;
|
|
2952
|
+
req.on("data", (chunk) => {
|
|
2953
|
+
size += chunk.length;
|
|
2954
|
+
if (size > MAX_BODY) {
|
|
2955
|
+
req.destroy();
|
|
2956
|
+
reject(new Error("Request body too large"));
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
chunks.push(chunk);
|
|
2960
|
+
});
|
|
2961
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
2962
|
+
req.on("error", reject);
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
sendJson(res, statusCode, data) {
|
|
2966
|
+
const body = JSON.stringify(data);
|
|
2967
|
+
res.writeHead(statusCode, {
|
|
2968
|
+
"Content-Type": "application/json",
|
|
2969
|
+
"Content-Length": Buffer.byteLength(body)
|
|
2970
|
+
});
|
|
2971
|
+
res.end(body);
|
|
2972
|
+
}
|
|
2973
|
+
/**
|
|
2974
|
+
* Start listening on 127.0.0.1 with OS-assigned port.
|
|
2975
|
+
* Returns the assigned port.
|
|
2976
|
+
*/
|
|
2977
|
+
listen() {
|
|
2978
|
+
return new Promise((resolve, reject) => {
|
|
2979
|
+
this.server.on("error", reject);
|
|
2980
|
+
this.server.listen(0, "127.0.0.1", () => {
|
|
2981
|
+
const addr = this.server.address();
|
|
2982
|
+
logger.info(`IPC server listening on 127.0.0.1:${addr.port}`);
|
|
2983
|
+
resolve(addr.port);
|
|
2984
|
+
});
|
|
2985
|
+
});
|
|
2986
|
+
}
|
|
2987
|
+
close() {
|
|
2988
|
+
return new Promise((resolve) => {
|
|
2989
|
+
for (const session of this.sessions.values()) {
|
|
2990
|
+
session.ws.close(1001, "Daemon shutting down");
|
|
2991
|
+
}
|
|
2992
|
+
this.wss.close();
|
|
2993
|
+
this.server.close(() => resolve());
|
|
2994
|
+
});
|
|
2995
|
+
}
|
|
2996
|
+
};
|
|
2997
|
+
|
|
2998
|
+
// src/daemon/lifecycle/sessionTracker.ts
|
|
2999
|
+
var GRACE_PERIOD_MS = 5e3;
|
|
3000
|
+
var SessionTracker = class {
|
|
3001
|
+
constructor() {
|
|
3002
|
+
this.ownership = /* @__PURE__ */ new Map();
|
|
3003
|
+
// tunnelId → ownership
|
|
3004
|
+
this.graceTimers = /* @__PURE__ */ new Map();
|
|
3005
|
+
}
|
|
3006
|
+
// tunnelId → timer
|
|
3007
|
+
/**
|
|
3008
|
+
* Register a tunnel as owned by a session in a specific mode.
|
|
3009
|
+
*/
|
|
3010
|
+
attach(tunnelId, sessionId, mode) {
|
|
3011
|
+
this.cancelGraceTimer(tunnelId);
|
|
3012
|
+
this.ownership.set(tunnelId, { tunnelId, sessionId, mode });
|
|
3013
|
+
logger.info(`Tunnel ${tunnelId} attached to session ${sessionId} (${mode})`);
|
|
3014
|
+
}
|
|
3015
|
+
/**
|
|
3016
|
+
* Mark a tunnel as detached (persists without session).
|
|
3017
|
+
*/
|
|
3018
|
+
markDetached(tunnelId) {
|
|
3019
|
+
const existing = this.ownership.get(tunnelId);
|
|
3020
|
+
if (existing) {
|
|
3021
|
+
existing.mode = SessionMode.Detached;
|
|
3022
|
+
existing.sessionId = "";
|
|
3023
|
+
logger.info(`Tunnel ${tunnelId} marked as detached`);
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
/**
|
|
3027
|
+
* Get the ownership info for a tunnel.
|
|
3028
|
+
*/
|
|
3029
|
+
getOwnership(tunnelId) {
|
|
3030
|
+
return this.ownership.get(tunnelId);
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* Get all tunnels owned by a session.
|
|
3034
|
+
*/
|
|
3035
|
+
getTunnelsForSession(sessionId) {
|
|
3036
|
+
return Array.from(this.ownership.values()).filter((o) => o.sessionId === sessionId);
|
|
3037
|
+
}
|
|
3038
|
+
/**
|
|
3039
|
+
* Called when a WS session disconnects.
|
|
3040
|
+
* Starts grace timers for all foreground tunnels owned by that session.
|
|
3041
|
+
*/
|
|
3042
|
+
onSessionDisconnect(session) {
|
|
3043
|
+
const sessionId = session.id;
|
|
3044
|
+
const tunnels = this.getTunnelsForSession(sessionId);
|
|
3045
|
+
for (const ownership of tunnels) {
|
|
3046
|
+
if (ownership.mode === SessionMode.Foreground) {
|
|
3047
|
+
logger.info(`Session ${sessionId} disconnected. Starting ${GRACE_PERIOD_MS}ms grace for tunnel ${ownership.tunnelId}`);
|
|
3048
|
+
this.startGraceTimer(ownership.tunnelId);
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* Called when a session re-attaches to a tunnel (e.g. CLI reconnects WS).
|
|
3054
|
+
* Cancels any pending grace timer.
|
|
3055
|
+
*/
|
|
3056
|
+
onSessionReconnect(tunnelId, sessionId) {
|
|
3057
|
+
this.cancelGraceTimer(tunnelId);
|
|
3058
|
+
const existing = this.ownership.get(tunnelId);
|
|
3059
|
+
if (existing) {
|
|
3060
|
+
existing.sessionId = sessionId;
|
|
3061
|
+
logger.info(`Tunnel ${tunnelId} re-attached to session ${sessionId}`);
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
/**
|
|
3065
|
+
* Remove ownership tracking for a tunnel (called when tunnel stops).
|
|
3066
|
+
*/
|
|
3067
|
+
removeTunnel(tunnelId) {
|
|
3068
|
+
this.cancelGraceTimer(tunnelId);
|
|
3069
|
+
this.ownership.delete(tunnelId);
|
|
3070
|
+
}
|
|
3071
|
+
/**
|
|
3072
|
+
* Check if a tunnel is in foreground mode.
|
|
3073
|
+
*/
|
|
3074
|
+
isForeground(tunnelId) {
|
|
3075
|
+
const o = this.ownership.get(tunnelId);
|
|
3076
|
+
return o?.mode === SessionMode.Foreground;
|
|
3077
|
+
}
|
|
3078
|
+
startGraceTimer(tunnelId) {
|
|
3079
|
+
this.cancelGraceTimer(tunnelId);
|
|
3080
|
+
const timer = setTimeout(() => {
|
|
3081
|
+
this.graceTimers.delete(tunnelId);
|
|
3082
|
+
this.killOrphanedTunnel(tunnelId);
|
|
3083
|
+
}, GRACE_PERIOD_MS);
|
|
3084
|
+
this.graceTimers.set(tunnelId, timer);
|
|
3085
|
+
}
|
|
3086
|
+
cancelGraceTimer(tunnelId) {
|
|
3087
|
+
const timer = this.graceTimers.get(tunnelId);
|
|
3088
|
+
if (timer) {
|
|
3089
|
+
clearTimeout(timer);
|
|
3090
|
+
this.graceTimers.delete(tunnelId);
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
killOrphanedTunnel(tunnelId) {
|
|
3094
|
+
const ownership = this.ownership.get(tunnelId);
|
|
3095
|
+
if (!ownership || ownership.mode !== SessionMode.Foreground) {
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
logger.info(`Grace period expired for tunnel ${tunnelId}. Stopping orphaned foreground tunnel.`);
|
|
3099
|
+
try {
|
|
3100
|
+
const manager = TunnelManager.getInstance();
|
|
3101
|
+
manager.stopTunnel(tunnelId);
|
|
3102
|
+
} catch (err) {
|
|
3103
|
+
logger.error(`Failed to stop orphaned tunnel ${tunnelId}`, { error: errorMessage(err) });
|
|
3104
|
+
}
|
|
3105
|
+
trackTunnelStop(tunnelId);
|
|
3106
|
+
this.ownership.delete(tunnelId);
|
|
3107
|
+
}
|
|
3108
|
+
/**
|
|
3109
|
+
* Cleanup all timers (for daemon shutdown).
|
|
3110
|
+
*/
|
|
3111
|
+
destroy() {
|
|
3112
|
+
for (const timer of this.graceTimers.values()) {
|
|
3113
|
+
clearTimeout(timer);
|
|
3114
|
+
}
|
|
3115
|
+
this.graceTimers.clear();
|
|
3116
|
+
this.ownership.clear();
|
|
3117
|
+
}
|
|
3118
|
+
};
|
|
3119
|
+
|
|
3120
|
+
// src/daemon/lifecycle/daemonChild.ts
|
|
3121
|
+
var daemonState = { tunnels: [], lastUpdated: "" };
|
|
3122
|
+
var sessionTrackerRef = null;
|
|
3123
|
+
function setDaemonSessionTracker(st) {
|
|
3124
|
+
sessionTrackerRef = st;
|
|
3125
|
+
}
|
|
3126
|
+
function writeDaemonInfo(info) {
|
|
3127
|
+
ensurePinggyConfigDir();
|
|
3128
|
+
const infoPath = getDaemonInfoPath();
|
|
3129
|
+
const tmpPath = infoPath + ".tmp";
|
|
3130
|
+
fs6.writeFileSync(tmpPath, JSON.stringify(info, null, 2), "utf-8");
|
|
3131
|
+
fs6.renameSync(tmpPath, infoPath);
|
|
3132
|
+
}
|
|
3133
|
+
function removeDaemonInfo() {
|
|
3134
|
+
try {
|
|
3135
|
+
const infoPath = getDaemonInfoPath();
|
|
3136
|
+
if (fs6.existsSync(infoPath)) fs6.unlinkSync(infoPath);
|
|
3137
|
+
} catch {
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
function trackTunnelStart(tunnelId, configId, name, origin, config, mode) {
|
|
3141
|
+
const entry = {
|
|
3142
|
+
tunnelId,
|
|
3143
|
+
configId,
|
|
3144
|
+
name,
|
|
3145
|
+
origin,
|
|
3146
|
+
config,
|
|
3147
|
+
mode,
|
|
3148
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3149
|
+
};
|
|
3150
|
+
addTunnelToState(daemonState, entry);
|
|
3151
|
+
persistDaemonState(daemonState);
|
|
3152
|
+
if (mode === SessionMode.Detached) {
|
|
3153
|
+
sessionTrackerRef?.attach(tunnelId, "", SessionMode.Detached);
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
function trackTunnelStop(tunnelId) {
|
|
3157
|
+
removeTunnelFromState(daemonState, tunnelId);
|
|
3158
|
+
persistDaemonState(daemonState);
|
|
3159
|
+
sessionTrackerRef?.removeTunnel(tunnelId);
|
|
3160
|
+
}
|
|
3161
|
+
function trackIPCTunnelStart(tunnelId, origin, mode = SessionMode.Detached) {
|
|
3162
|
+
if (mode === SessionMode.Foreground) return;
|
|
3163
|
+
const manager = TunnelManager.getInstance();
|
|
3164
|
+
const managed = manager.getManagedTunnel("", tunnelId);
|
|
3165
|
+
if (!managed?.tunnelConfig) return;
|
|
3166
|
+
trackTunnelStart(
|
|
3167
|
+
tunnelId,
|
|
3168
|
+
managed.configId,
|
|
3169
|
+
managed.tunnelName ?? "",
|
|
3170
|
+
origin,
|
|
3171
|
+
managed.tunnelConfig,
|
|
3172
|
+
SessionMode.Detached
|
|
3173
|
+
);
|
|
3174
|
+
}
|
|
3175
|
+
async function startSavedTunnel(saved, manager) {
|
|
3176
|
+
const config = {
|
|
3177
|
+
...saved.tunnelConfig,
|
|
3178
|
+
configId: saved.configId,
|
|
3179
|
+
name: saved.name,
|
|
3180
|
+
optional: {
|
|
3181
|
+
...saved.tunnelConfig.optional,
|
|
3182
|
+
noTui: true
|
|
3183
|
+
}
|
|
3184
|
+
};
|
|
3185
|
+
const tunnel = await manager.createTunnel(config, "cli");
|
|
3186
|
+
await manager.startTunnel(tunnel.tunnelid);
|
|
3187
|
+
const urls = await manager.getTunnelUrls(tunnel.tunnelid);
|
|
3188
|
+
logger.info(`Tunnel "${saved.name}" started`, { tunnelId: tunnel.tunnelid, urls });
|
|
3189
|
+
trackTunnelStart(tunnel.tunnelid, saved.configId, saved.name, "cli", saved.tunnelConfig, SessionMode.Detached);
|
|
3190
|
+
await manager.registerWorkerErrorListner(tunnel.tunnelid, (_id, error) => {
|
|
3191
|
+
logger.error(`[${saved.name}] Fatal error: ${error.message}`);
|
|
3192
|
+
});
|
|
3193
|
+
await manager.registerReconnectingListener(tunnel.tunnelid, (_id, retryCnt) => {
|
|
3194
|
+
logger.info(`[${saved.name}] Reconnecting (attempt #${retryCnt})`);
|
|
3195
|
+
});
|
|
3196
|
+
await manager.registerReconnectionCompletedListener(tunnel.tunnelid, (_id, newUrls) => {
|
|
3197
|
+
logger.info(`[${saved.name}] Reconnected`, { urls: newUrls });
|
|
3198
|
+
});
|
|
3199
|
+
await manager.registerReconnectionFailedListener(tunnel.tunnelid, (_id, retryCnt) => {
|
|
3200
|
+
logger.error(`[${saved.name}] Reconnection failed after ${retryCnt} attempts`);
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
async function restoreCrashedTunnels(manager) {
|
|
3204
|
+
const savedState = loadDaemonState();
|
|
3205
|
+
if (!savedState || savedState.tunnels.length === 0) return;
|
|
3206
|
+
const detachedTunnels = savedState.tunnels.filter((t) => t.mode === "detached");
|
|
3207
|
+
if (detachedTunnels.length === 0) {
|
|
3208
|
+
logger.info("Crash recovery: no detached tunnels to restore");
|
|
3209
|
+
clearDaemonState();
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
logger.info(`Crash recovery: restoring ${detachedTunnels.length} detached tunnel(s)`);
|
|
3213
|
+
for (const entry of detachedTunnels) {
|
|
3214
|
+
try {
|
|
3215
|
+
const config = {
|
|
3216
|
+
...entry.config,
|
|
3217
|
+
configId: entry.configId,
|
|
3218
|
+
name: entry.name,
|
|
3219
|
+
tunnelid: entry.tunnelId,
|
|
3220
|
+
// reuse stable ID for log file continuity
|
|
3221
|
+
optional: {
|
|
3222
|
+
...entry.config.optional,
|
|
3223
|
+
noTui: true
|
|
3224
|
+
}
|
|
3225
|
+
};
|
|
3226
|
+
const tunnel = await manager.createTunnel(config, entry.origin);
|
|
3227
|
+
await manager.startTunnel(tunnel.tunnelid);
|
|
3228
|
+
const urls = await manager.getTunnelUrls(tunnel.tunnelid);
|
|
3229
|
+
logger.info(`Restored tunnel "${entry.name}"`, { tunnelId: tunnel.tunnelid, urls });
|
|
3230
|
+
trackTunnelStart(tunnel.tunnelid, entry.configId, entry.name, entry.origin, entry.config, SessionMode.Detached);
|
|
3231
|
+
await manager.registerWorkerErrorListner(tunnel.tunnelid, (_id, error) => {
|
|
3232
|
+
logger.error(`[${entry.name}] Fatal error: ${error.message}`);
|
|
3233
|
+
});
|
|
3234
|
+
await manager.registerReconnectingListener(tunnel.tunnelid, (_id, retryCnt) => {
|
|
3235
|
+
logger.info(`[${entry.name}] Reconnecting (attempt #${retryCnt})`);
|
|
3236
|
+
});
|
|
3237
|
+
await manager.registerReconnectionCompletedListener(tunnel.tunnelid, (_id, newUrls) => {
|
|
3238
|
+
logger.info(`[${entry.name}] Reconnected`, { urls: newUrls });
|
|
3239
|
+
});
|
|
3240
|
+
await manager.registerReconnectionFailedListener(tunnel.tunnelid, (_id, retryCnt) => {
|
|
3241
|
+
logger.error(`[${entry.name}] Reconnection failed after ${retryCnt} attempts`);
|
|
3242
|
+
});
|
|
3243
|
+
} catch (err) {
|
|
3244
|
+
logger.error(`Failed to restore tunnel "${entry.name}"`, { error: errorMessage(err) });
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
async function runDaemonChild(opts = {}) {
|
|
3249
|
+
const installSignalHandlers = opts.installSignalHandlers ?? true;
|
|
3250
|
+
const exitOnFailure = opts.exitOnFailure ?? true;
|
|
3251
|
+
ensurePinggyConfigDir();
|
|
3252
|
+
const persistedLevel = readDaemonConfig()?.logLevel;
|
|
3253
|
+
const envLevel = process.env.PINGGY_LOG_LEVEL;
|
|
3254
|
+
const initialLevel = persistedLevel ?? envLevel ?? "info";
|
|
3255
|
+
setLogLevel(initialLevel);
|
|
3256
|
+
ensurePinggyLogDir();
|
|
3257
|
+
const logPath = getDaemonLogPath();
|
|
3258
|
+
enablePackageLogging({
|
|
3259
|
+
level: initialLevel,
|
|
3260
|
+
filePath: logPath,
|
|
3261
|
+
stdout: false,
|
|
3262
|
+
enableSdkLog: true
|
|
3263
|
+
});
|
|
3264
|
+
logger.info("Daemon starting", { pid: process.pid, inProcess: !installSignalHandlers });
|
|
3265
|
+
const manager = TunnelManager.getInstance();
|
|
3266
|
+
const ipcServer = new IPCServer();
|
|
3267
|
+
const sessionTracker = new SessionTracker();
|
|
3268
|
+
ipcServer.setOnSessionDisconnect((session) => {
|
|
3269
|
+
sessionTracker.onSessionDisconnect(session);
|
|
3270
|
+
});
|
|
3271
|
+
ipcServer.setSessionTracker(sessionTracker);
|
|
3272
|
+
setDaemonSessionTracker(sessionTracker);
|
|
3273
|
+
let cleanedUp = false;
|
|
3274
|
+
const cleanup = () => {
|
|
3275
|
+
if (cleanedUp) return;
|
|
3276
|
+
cleanedUp = true;
|
|
3277
|
+
logger.info("Daemon shutting down");
|
|
3278
|
+
try {
|
|
3279
|
+
removeDaemonInfo();
|
|
3280
|
+
} catch {
|
|
3281
|
+
}
|
|
3282
|
+
try {
|
|
3283
|
+
clearDaemonState();
|
|
3284
|
+
} catch {
|
|
3285
|
+
}
|
|
3286
|
+
try {
|
|
3287
|
+
sessionTracker.destroy();
|
|
3288
|
+
} catch {
|
|
3289
|
+
}
|
|
3290
|
+
setDaemonSessionTracker(null);
|
|
3291
|
+
try {
|
|
3292
|
+
detachAllTunnelLoggers();
|
|
3293
|
+
} catch {
|
|
3294
|
+
}
|
|
3295
|
+
try {
|
|
3296
|
+
manager.stopAllTunnels();
|
|
3297
|
+
} catch {
|
|
3298
|
+
}
|
|
3299
|
+
try {
|
|
3300
|
+
void ipcServer.close();
|
|
3301
|
+
} catch {
|
|
3302
|
+
}
|
|
3303
|
+
};
|
|
3304
|
+
if (installSignalHandlers) {
|
|
3305
|
+
process.on("SIGTERM", () => {
|
|
3306
|
+
cleanup();
|
|
3307
|
+
process.exit(0);
|
|
3308
|
+
});
|
|
3309
|
+
process.on("SIGINT", () => {
|
|
3310
|
+
cleanup();
|
|
3311
|
+
process.exit(0);
|
|
3312
|
+
});
|
|
3313
|
+
process.on("exit", cleanup);
|
|
3314
|
+
process.on("uncaughtException", (err) => {
|
|
3315
|
+
logger.error("Daemon uncaught exception", { error: err.message, stack: err.stack });
|
|
3316
|
+
});
|
|
3317
|
+
process.on("unhandledRejection", (reason) => {
|
|
3318
|
+
logger.error("Daemon unhandled rejection", { reason: String(reason) });
|
|
3319
|
+
});
|
|
3320
|
+
}
|
|
3321
|
+
try {
|
|
3322
|
+
const port = await ipcServer.listen();
|
|
3323
|
+
const info = {
|
|
3324
|
+
pid: process.pid,
|
|
3325
|
+
port,
|
|
3326
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3327
|
+
};
|
|
3328
|
+
writeDaemonInfo(info);
|
|
3329
|
+
logger.info("Daemon info written", info);
|
|
3330
|
+
await restoreCrashedTunnels(manager);
|
|
3331
|
+
const configs = getAutoStartConfigs();
|
|
3332
|
+
if (configs.length > 0) {
|
|
3333
|
+
logger.info(`Starting ${configs.length} auto-start tunnel(s)`);
|
|
3334
|
+
for (const saved of configs) {
|
|
3335
|
+
try {
|
|
3336
|
+
await startSavedTunnel(saved, manager);
|
|
3337
|
+
} catch (err) {
|
|
3338
|
+
logger.error(`Failed to start tunnel "${saved.name}"`, { error: errorMessage(err) });
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
} else {
|
|
3342
|
+
logger.info("No auto-start tunnels configured");
|
|
3343
|
+
}
|
|
3344
|
+
logger.info("Daemon ready", { pid: process.pid, port });
|
|
3345
|
+
return {
|
|
3346
|
+
pid: process.pid,
|
|
3347
|
+
port,
|
|
3348
|
+
shutdown: cleanup
|
|
3349
|
+
};
|
|
3350
|
+
} catch (err) {
|
|
3351
|
+
logger.error("Daemon failed to start", { error: errorMessage(err) });
|
|
3352
|
+
removeDaemonInfo();
|
|
3353
|
+
if (exitOnFailure) {
|
|
3354
|
+
process.exit(1);
|
|
3355
|
+
}
|
|
3356
|
+
throw err;
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
// src/main.ts
|
|
3361
|
+
async function main() {
|
|
3362
|
+
try {
|
|
3363
|
+
const rawArgs = process.argv.slice(2);
|
|
3364
|
+
const { values, positionals, hasAnyArgs } = parseCliArgs(cliOptions);
|
|
3365
|
+
configureLogger(values);
|
|
3366
|
+
if (values["_daemon-child"]) {
|
|
3367
|
+
const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-KXERF36J.js");
|
|
3368
|
+
await runDaemonChild2();
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
3371
|
+
if (isSubcommand(rawArgs)) {
|
|
3372
|
+
await handleSubcommand(rawArgs);
|
|
3373
|
+
return;
|
|
3374
|
+
}
|
|
3375
|
+
if (!hasAnyArgs || values.help) {
|
|
3376
|
+
printHelpMessage();
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
if (values.version) {
|
|
3380
|
+
printer_default.print(`Pinggy CLI version: ${getVersion()}`);
|
|
3381
|
+
return;
|
|
3382
|
+
}
|
|
3383
|
+
await buildAndStartTunnel(values, positionals);
|
|
3384
|
+
} catch (error) {
|
|
3385
|
+
logger.error("Unhandled error in CLI:", error);
|
|
3386
|
+
printer_default.fatal(error);
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
var currentFile = fileURLToPath(import.meta.url);
|
|
3390
|
+
var entryFile = null;
|
|
3391
|
+
try {
|
|
3392
|
+
entryFile = argv[1] ? realpathSync(argv[1]) : null;
|
|
3393
|
+
} catch (e) {
|
|
3394
|
+
entryFile = null;
|
|
3395
|
+
}
|
|
3396
|
+
if (entryFile && entryFile === currentFile) {
|
|
3397
|
+
void main();
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
export {
|
|
3401
|
+
setDaemonSessionTracker,
|
|
3402
|
+
removeDaemonInfo,
|
|
3403
|
+
trackTunnelStart,
|
|
3404
|
+
trackTunnelStop,
|
|
3405
|
+
trackIPCTunnelStart,
|
|
3406
|
+
runDaemonChild
|
|
3407
|
+
};
|