@kadi.build/tunnel-services 1.0.0
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/README.md +343 -0
- package/package.json +58 -0
- package/src/BaseTunnelService.js +300 -0
- package/src/TunnelManager.js +372 -0
- package/src/TunnelService.js +316 -0
- package/src/errors.js +115 -0
- package/src/index.js +78 -0
- package/src/services/KadiTunnelService.js +421 -0
- package/src/services/LocalTunnelService.js +188 -0
- package/src/services/LocalhostRunTunnelService.js +241 -0
- package/src/services/NgrokTunnelService.js +322 -0
- package/src/services/PinggyTunnelService.js +262 -0
- package/src/services/ServeoTunnelService.js +293 -0
- package/src/utils/ProcessManager.js +175 -0
- package/src/utils/TunnelDiagnosticTool.js +282 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Ngrok tunnel service implementation
|
|
3
|
+
* Uses the modern @ngrok/ngrok SDK (no binary spawning).
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - HTTP/HTTPS tunneling with automatic HTTPS
|
|
7
|
+
* - Custom domain support (paid accounts)
|
|
8
|
+
* - Authentication token support
|
|
9
|
+
* - Clean listener-based lifecycle (no child process management)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import createDebug from 'debug';
|
|
13
|
+
import { BaseTunnelService } from '../BaseTunnelService.js';
|
|
14
|
+
import {
|
|
15
|
+
TransientTunnelError,
|
|
16
|
+
PermanentTunnelError,
|
|
17
|
+
ConnectionTimeoutError,
|
|
18
|
+
AuthenticationFailedError
|
|
19
|
+
} from '../errors.js';
|
|
20
|
+
|
|
21
|
+
const debug = createDebug('kadi:tunnel:ngrok');
|
|
22
|
+
|
|
23
|
+
// Modern @ngrok/ngrok SDK — no binary, no child process
|
|
24
|
+
let ngrok;
|
|
25
|
+
try {
|
|
26
|
+
ngrok = await import('@ngrok/ngrok');
|
|
27
|
+
} catch {
|
|
28
|
+
// Fall back to legacy 'ngrok' package if @ngrok/ngrok isn't installed
|
|
29
|
+
try {
|
|
30
|
+
ngrok = await import('ngrok');
|
|
31
|
+
ngrok.__legacy = true;
|
|
32
|
+
} catch {
|
|
33
|
+
ngrok = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* NgrokTunnelService
|
|
39
|
+
*
|
|
40
|
+
* Provides an HTTP/HTTPS tunnel via the modern @ngrok/ngrok SDK.
|
|
41
|
+
* Falls back to the legacy 'ngrok' npm package if @ngrok/ngrok is not installed.
|
|
42
|
+
*
|
|
43
|
+
* @extends BaseTunnelService
|
|
44
|
+
*/
|
|
45
|
+
export default class NgrokTunnelService extends BaseTunnelService {
|
|
46
|
+
|
|
47
|
+
constructor(config = {}) {
|
|
48
|
+
super(config);
|
|
49
|
+
this.DEFAULT_TIMEOUT = 45000;
|
|
50
|
+
this.authToken = config.ngrokAuthToken || config.authToken ||
|
|
51
|
+
process.env.NGROK_AUTHTOKEN || process.env.NGROK_AUTH_TOKEN || null;
|
|
52
|
+
this.region = config.region || 'us';
|
|
53
|
+
this._listeners = new Map(); // tunnelId -> Listener
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get name() {
|
|
57
|
+
return 'ngrok';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async connect(options) {
|
|
61
|
+
if (!options || typeof options.port !== 'number') {
|
|
62
|
+
throw new PermanentTunnelError('Port number is required for Ngrok tunnel');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (options.port < 1 || options.port > 65535) {
|
|
66
|
+
throw new PermanentTunnelError(`Invalid port number: ${options.port}. Must be between 1-65535`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!ngrok) {
|
|
70
|
+
throw new PermanentTunnelError(
|
|
71
|
+
'Neither @ngrok/ngrok nor ngrok packages are installed. ' +
|
|
72
|
+
'Install with: npm install @ngrok/ngrok'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tunnelId = this._generateTunnelId();
|
|
77
|
+
const timeout = options.timeout || this.DEFAULT_TIMEOUT;
|
|
78
|
+
const authToken = options.authToken || this.authToken;
|
|
79
|
+
const protocol = options.protocol || 'http';
|
|
80
|
+
|
|
81
|
+
this._emitProgress('connecting', 'Establishing Ngrok tunnel...', tunnelId);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const sdkType = ngrok.__legacy ? 'legacy' : 'modern';
|
|
85
|
+
debug(`🔗 Creating Ngrok tunnel [${sdkType}]: ${protocol} localhost:${options.port}`);
|
|
86
|
+
|
|
87
|
+
let url;
|
|
88
|
+
|
|
89
|
+
// ── Modern SDK path (@ngrok/ngrok) ─────────────────────────────
|
|
90
|
+
if (!ngrok.__legacy) {
|
|
91
|
+
const forwardOpts = {
|
|
92
|
+
addr: options.port,
|
|
93
|
+
authtoken: authToken || undefined,
|
|
94
|
+
authtoken_from_env: !authToken,
|
|
95
|
+
proto: protocol === 'tcp' ? 'tcp' : 'http',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (options.subdomain) {
|
|
99
|
+
forwardOpts.domain = options.subdomain;
|
|
100
|
+
debug(` 📍 Custom domain: ${options.subdomain}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const listener = await Promise.race([
|
|
104
|
+
ngrok.forward(forwardOpts),
|
|
105
|
+
new Promise((_, reject) =>
|
|
106
|
+
setTimeout(() => reject(new ConnectionTimeoutError(this.name, timeout)), timeout)
|
|
107
|
+
)
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
url = listener.url();
|
|
111
|
+
this._listeners.set(tunnelId, listener);
|
|
112
|
+
|
|
113
|
+
// ── Legacy SDK path (ngrok v4.x wrapper) ──────────────────────
|
|
114
|
+
} else {
|
|
115
|
+
const ngrokOptions = {
|
|
116
|
+
addr: options.port,
|
|
117
|
+
proto: 'http',
|
|
118
|
+
authtoken: authToken || undefined,
|
|
119
|
+
region: options.region || (this.region !== 'us' ? this.region : undefined),
|
|
120
|
+
subdomain: options.subdomain || undefined,
|
|
121
|
+
...(options.ngrokOptions || {})
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Defensive cleanup for legacy binary
|
|
125
|
+
try { await ngrok.disconnect(); } catch {}
|
|
126
|
+
try { await ngrok.kill(); } catch {}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
url = await Promise.race([
|
|
130
|
+
ngrok.connect(ngrokOptions),
|
|
131
|
+
new Promise((_, reject) =>
|
|
132
|
+
setTimeout(() => reject(new ConnectionTimeoutError(this.name, timeout)), timeout)
|
|
133
|
+
)
|
|
134
|
+
]);
|
|
135
|
+
} catch (firstError) {
|
|
136
|
+
const msg = (firstError && (firstError.msg || firstError.message)) || String(firstError);
|
|
137
|
+
const rawBody = firstError && (firstError.body || (firstError.response && firstError.response.body));
|
|
138
|
+
let bodyString = '';
|
|
139
|
+
if (typeof rawBody === 'string') bodyString = rawBody;
|
|
140
|
+
else if (rawBody) {
|
|
141
|
+
try { bodyString = JSON.stringify(rawBody); } catch { bodyString = ''; }
|
|
142
|
+
}
|
|
143
|
+
const alreadyExists = msg.includes('already exists') || bodyString.includes('already exists');
|
|
144
|
+
const invalidConfig = msg.includes('invalid tunnel configuration') || bodyString.includes('invalid tunnel configuration');
|
|
145
|
+
|
|
146
|
+
if (alreadyExists || invalidConfig) {
|
|
147
|
+
try { await ngrok.disconnect(); } catch {}
|
|
148
|
+
try { await ngrok.kill(); } catch {}
|
|
149
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
150
|
+
|
|
151
|
+
url = await Promise.race([
|
|
152
|
+
ngrok.connect(ngrokOptions),
|
|
153
|
+
new Promise((_, reject) =>
|
|
154
|
+
setTimeout(() => reject(new ConnectionTimeoutError(this.name, timeout)), timeout)
|
|
155
|
+
)
|
|
156
|
+
]);
|
|
157
|
+
} else {
|
|
158
|
+
throw firstError;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Store tunnel information
|
|
164
|
+
const tunnelInfo = {
|
|
165
|
+
id: tunnelId,
|
|
166
|
+
serviceName: 'ngrok',
|
|
167
|
+
localPort: options.port,
|
|
168
|
+
region: this.region,
|
|
169
|
+
protocol: protocol,
|
|
170
|
+
url: url,
|
|
171
|
+
ngrokUrl: url,
|
|
172
|
+
createdAt: new Date(),
|
|
173
|
+
status: 'active',
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
this.activeTunnels.set(tunnelId, tunnelInfo);
|
|
177
|
+
|
|
178
|
+
this._emitProgress('connected', 'Ngrok tunnel established', tunnelId);
|
|
179
|
+
this._emitTunnelCreated(tunnelInfo);
|
|
180
|
+
|
|
181
|
+
debug(`✅ Ngrok tunnel established: ${url}`);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
tunnelId,
|
|
185
|
+
url,
|
|
186
|
+
subdomain: this._extractSubdomain(url),
|
|
187
|
+
localPort: options.port,
|
|
188
|
+
createdAt: new Date(),
|
|
189
|
+
status: 'active',
|
|
190
|
+
service: 'ngrok',
|
|
191
|
+
region: this.region,
|
|
192
|
+
protocol,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
} catch (error) {
|
|
196
|
+
this.activeTunnels.delete(tunnelId);
|
|
197
|
+
this._listeners.delete(tunnelId);
|
|
198
|
+
|
|
199
|
+
if (error instanceof ConnectionTimeoutError) {
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const errorMessage = error.message || error.toString();
|
|
204
|
+
|
|
205
|
+
if (errorMessage.includes('authentication') || errorMessage.includes('authtoken') || errorMessage.includes('ERR_NGROK_') || errorMessage.includes('invalid token')) {
|
|
206
|
+
throw new AuthenticationFailedError(
|
|
207
|
+
this.name,
|
|
208
|
+
`Ngrok authentication failed: ${errorMessage}. Check your NGROK_AUTHTOKEN.`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (errorMessage.includes('subdomain') && (errorMessage.includes('reserved') || errorMessage.includes('unavailable'))) {
|
|
213
|
+
throw new PermanentTunnelError(
|
|
214
|
+
'Subdomain is reserved or unavailable. Try a different subdomain.'
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('network') || errorMessage.includes('timeout')) {
|
|
219
|
+
throw new TransientTunnelError(`Network error: ${errorMessage}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
throw new TransientTunnelError(`Ngrok tunnel creation failed: ${errorMessage}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async disconnect(tunnelId) {
|
|
227
|
+
const tunnel = this.activeTunnels.get(tunnelId);
|
|
228
|
+
if (!tunnel) {
|
|
229
|
+
throw new Error(`Tunnel ${tunnelId} not found`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
this._emitProgress('disconnecting', 'Destroying Ngrok tunnel...', tunnelId);
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
// Modern SDK: close the listener directly
|
|
236
|
+
const listener = this._listeners.get(tunnelId);
|
|
237
|
+
if (listener && typeof listener.close === 'function') {
|
|
238
|
+
await listener.close();
|
|
239
|
+
debug(`🔌 Ngrok listener closed: ${tunnel.ngrokUrl}`);
|
|
240
|
+
} else if (ngrok && !ngrok.__legacy) {
|
|
241
|
+
await ngrok.disconnect(tunnel.ngrokUrl);
|
|
242
|
+
debug(`🔌 Ngrok tunnel disconnected: ${tunnel.ngrokUrl}`);
|
|
243
|
+
} else if (ngrok) {
|
|
244
|
+
if (tunnel.ngrokUrl) {
|
|
245
|
+
await ngrok.disconnect(tunnel.ngrokUrl);
|
|
246
|
+
} else {
|
|
247
|
+
await ngrok.disconnect();
|
|
248
|
+
}
|
|
249
|
+
debug(`🔌 Ngrok tunnel disconnected (legacy): ${tunnel.ngrokUrl}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
this.activeTunnels.delete(tunnelId);
|
|
253
|
+
this._listeners.delete(tunnelId);
|
|
254
|
+
|
|
255
|
+
if (ngrok?.__legacy && this.activeTunnels.size === 0) {
|
|
256
|
+
try { await ngrok.kill(); } catch {}
|
|
257
|
+
debug('✅ Ngrok process terminated (legacy)');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
this._emitProgress('disconnected', 'Ngrok tunnel destroyed', tunnelId);
|
|
261
|
+
this._emitTunnelDestroyed(tunnelId);
|
|
262
|
+
debug(`✅ Ngrok tunnel ${tunnelId} disconnected`);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
debug(`❌ Error disconnecting Ngrok tunnel ${tunnelId}: ${error.message}`);
|
|
265
|
+
this.activeTunnels.delete(tunnelId);
|
|
266
|
+
this._listeners.delete(tunnelId);
|
|
267
|
+
this._emitTunnelDestroyed(tunnelId);
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
getStatus() {
|
|
273
|
+
return {
|
|
274
|
+
serviceName: this.name,
|
|
275
|
+
activeTunnels: this.activeTunnels.size,
|
|
276
|
+
available: !!ngrok,
|
|
277
|
+
modern: ngrok ? !ngrok.__legacy : false,
|
|
278
|
+
status: `${this.activeTunnels.size} active tunnels`,
|
|
279
|
+
region: this.region,
|
|
280
|
+
authConfigured: !!this.authToken
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_extractSubdomain(url) {
|
|
285
|
+
try {
|
|
286
|
+
const parsed = new URL(url);
|
|
287
|
+
const hostname = parsed.hostname;
|
|
288
|
+
if (hostname.includes('.ngrok.')) {
|
|
289
|
+
const parts = hostname.split('.');
|
|
290
|
+
return parts.length > 2 ? parts[0] : null;
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async shutdown() {
|
|
299
|
+
debug('🛑 Shutting down Ngrok service...');
|
|
300
|
+
|
|
301
|
+
for (const [tunnelId, listener] of this._listeners) {
|
|
302
|
+
try {
|
|
303
|
+
if (typeof listener.close === 'function') {
|
|
304
|
+
await listener.close();
|
|
305
|
+
}
|
|
306
|
+
} catch (error) {
|
|
307
|
+
debug('Warning during listener close:', error.message);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
this._listeners.clear();
|
|
311
|
+
|
|
312
|
+
if (ngrok?.__legacy) {
|
|
313
|
+
try { await ngrok.disconnect(); } catch {}
|
|
314
|
+
try { await ngrok.kill(); } catch {}
|
|
315
|
+
debug('✅ Legacy ngrok process terminated');
|
|
316
|
+
} else if (ngrok) {
|
|
317
|
+
try { await ngrok.disconnect(); } catch {}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
await super.shutdown();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Pinggy tunnel service implementation
|
|
3
|
+
* Provides SSH-based tunneling through pinggy.io
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import createDebug from 'debug';
|
|
7
|
+
import { BaseTunnelService } from '../BaseTunnelService.js';
|
|
8
|
+
import {
|
|
9
|
+
TransientTunnelError,
|
|
10
|
+
PermanentTunnelError,
|
|
11
|
+
SSHUnavailableError,
|
|
12
|
+
ConnectionTimeoutError
|
|
13
|
+
} from '../errors.js';
|
|
14
|
+
import { spawn } from 'child_process';
|
|
15
|
+
|
|
16
|
+
const debug = createDebug('kadi:tunnel:pinggy');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pinggy tunnel service implementation
|
|
20
|
+
*
|
|
21
|
+
* Provides SSH-based tunneling through pinggy.io with support for:
|
|
22
|
+
* - Custom subdomains with {subdomain}.a.pinggy.io format
|
|
23
|
+
* - 60-minute timeout warnings for free accounts
|
|
24
|
+
* - Automatic URL extraction from SSH output
|
|
25
|
+
* - Process management and cleanup
|
|
26
|
+
* - Comprehensive error handling
|
|
27
|
+
*
|
|
28
|
+
* @extends BaseTunnelService
|
|
29
|
+
*/
|
|
30
|
+
export default class PinggyTunnelService extends BaseTunnelService {
|
|
31
|
+
|
|
32
|
+
constructor(config) {
|
|
33
|
+
super(config);
|
|
34
|
+
this.DEFAULT_TIMEOUT = 30000;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get name() {
|
|
38
|
+
return 'pinggy';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async connect(options) {
|
|
42
|
+
if (!options || typeof options.port !== 'number') {
|
|
43
|
+
throw new PermanentTunnelError('Port number is required for Pinggy tunnel');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const tunnelId = this._generateTunnelId();
|
|
47
|
+
const timeout = options.timeout || this.DEFAULT_TIMEOUT;
|
|
48
|
+
|
|
49
|
+
this._emitProgress('connecting', 'Establishing Pinggy tunnel...', tunnelId);
|
|
50
|
+
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
let resolved = false;
|
|
53
|
+
let sshProcess = null;
|
|
54
|
+
let timeoutHandle = null;
|
|
55
|
+
|
|
56
|
+
const sshArgs = this._buildSSHArgs(options.subdomain, options.port);
|
|
57
|
+
|
|
58
|
+
debug(`🔗 Creating Pinggy tunnel: ssh ${sshArgs.join(' ')}`);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
sshProcess = spawn('ssh', sshArgs, {
|
|
62
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const tunnelInfo = {
|
|
66
|
+
id: tunnelId,
|
|
67
|
+
serviceName: 'pinggy',
|
|
68
|
+
localPort: options.port,
|
|
69
|
+
process: sshProcess,
|
|
70
|
+
createdAt: new Date(),
|
|
71
|
+
status: 'connecting'
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
this.activeTunnels.set(tunnelId, tunnelInfo);
|
|
75
|
+
|
|
76
|
+
let output = '';
|
|
77
|
+
let tunnelUrl = null;
|
|
78
|
+
|
|
79
|
+
const handleOutput = (data) => {
|
|
80
|
+
const text = data.toString();
|
|
81
|
+
output += text;
|
|
82
|
+
debug('Pinggy output:', text.trim());
|
|
83
|
+
|
|
84
|
+
tunnelUrl = this._extractPinggyUrl(output);
|
|
85
|
+
if (tunnelUrl && !resolved) {
|
|
86
|
+
resolved = true;
|
|
87
|
+
|
|
88
|
+
if (timeoutHandle) {
|
|
89
|
+
clearTimeout(timeoutHandle);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
tunnelInfo.status = 'active';
|
|
93
|
+
tunnelInfo.url = tunnelUrl;
|
|
94
|
+
tunnelInfo.timeoutWarning = 'Tunnel will timeout after 60 minutes on free plan';
|
|
95
|
+
|
|
96
|
+
this._emitProgress('connected', 'Pinggy tunnel established', tunnelId);
|
|
97
|
+
this._emitTunnelCreated(tunnelInfo);
|
|
98
|
+
|
|
99
|
+
debug('⚠️ Note: Pinggy free tunnels timeout after 60 minutes');
|
|
100
|
+
|
|
101
|
+
resolve({
|
|
102
|
+
tunnelId: tunnelId,
|
|
103
|
+
url: tunnelUrl,
|
|
104
|
+
subdomain: this._extractSubdomain(tunnelUrl),
|
|
105
|
+
localPort: options.port,
|
|
106
|
+
createdAt: new Date(),
|
|
107
|
+
status: 'active',
|
|
108
|
+
service: 'pinggy',
|
|
109
|
+
timeoutWarning: 'Tunnel will timeout after 60 minutes on free plan'
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
sshProcess.stdout.on('data', handleOutput);
|
|
115
|
+
sshProcess.stderr.on('data', handleOutput);
|
|
116
|
+
|
|
117
|
+
sshProcess.on('error', (error) => {
|
|
118
|
+
if (!resolved) {
|
|
119
|
+
resolved = true;
|
|
120
|
+
|
|
121
|
+
if (timeoutHandle) {
|
|
122
|
+
clearTimeout(timeoutHandle);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.activeTunnels.delete(tunnelId);
|
|
126
|
+
|
|
127
|
+
if (error.code === 'ENOENT') {
|
|
128
|
+
reject(new SSHUnavailableError());
|
|
129
|
+
} else {
|
|
130
|
+
reject(new TransientTunnelError(`Pinggy tunnel failed: ${error.message}`));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
sshProcess.on('exit', (code) => {
|
|
136
|
+
if (!resolved) {
|
|
137
|
+
resolved = true;
|
|
138
|
+
|
|
139
|
+
if (timeoutHandle) {
|
|
140
|
+
clearTimeout(timeoutHandle);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.activeTunnels.delete(tunnelId);
|
|
144
|
+
|
|
145
|
+
if (code !== 0) {
|
|
146
|
+
reject(new TransientTunnelError(`Pinggy tunnel exited with code ${code}. This might be due to the 60-minute timeout.`));
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
this.activeTunnels.delete(tunnelId);
|
|
150
|
+
this._emitTunnelDestroyed(tunnelId);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
timeoutHandle = setTimeout(() => {
|
|
155
|
+
if (!resolved) {
|
|
156
|
+
resolved = true;
|
|
157
|
+
|
|
158
|
+
if (sshProcess && !sshProcess.killed) {
|
|
159
|
+
sshProcess.kill();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this.activeTunnels.delete(tunnelId);
|
|
163
|
+
reject(new ConnectionTimeoutError('pinggy', timeout));
|
|
164
|
+
}
|
|
165
|
+
}, timeout);
|
|
166
|
+
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (!resolved) {
|
|
169
|
+
resolved = true;
|
|
170
|
+
reject(new TransientTunnelError(`Failed to start Pinggy tunnel: ${error.message}`));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async disconnect(tunnelId) {
|
|
177
|
+
const tunnelInfo = this.activeTunnels.get(tunnelId);
|
|
178
|
+
|
|
179
|
+
if (!tunnelInfo) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
if (tunnelInfo.process && !tunnelInfo.process.killed) {
|
|
185
|
+
tunnelInfo.process.kill();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this.activeTunnels.delete(tunnelId);
|
|
189
|
+
this._emitTunnelDestroyed(tunnelId);
|
|
190
|
+
|
|
191
|
+
return true;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
this.activeTunnels.delete(tunnelId);
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
getStatus() {
|
|
199
|
+
return {
|
|
200
|
+
serviceName: 'pinggy',
|
|
201
|
+
available: true,
|
|
202
|
+
isActive: this.activeTunnels.size > 0,
|
|
203
|
+
activeTunnels: this.activeTunnels.size,
|
|
204
|
+
tunnels: Array.from(this.activeTunnels.values()).map(tunnel => ({
|
|
205
|
+
id: tunnel.id,
|
|
206
|
+
url: tunnel.url,
|
|
207
|
+
localPort: tunnel.localPort,
|
|
208
|
+
status: tunnel.status,
|
|
209
|
+
createdAt: tunnel.createdAt,
|
|
210
|
+
timeoutWarning: tunnel.timeoutWarning
|
|
211
|
+
}))
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_buildSSHArgs(subdomain, port) {
|
|
216
|
+
const host = subdomain ? `${subdomain}.a.pinggy.io` : 'a.pinggy.io';
|
|
217
|
+
|
|
218
|
+
return [
|
|
219
|
+
'-p', '443',
|
|
220
|
+
'-R', `0:localhost:${port}`,
|
|
221
|
+
`qr@${host}`,
|
|
222
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
223
|
+
'-o', 'ServerAliveInterval=30',
|
|
224
|
+
'-o', 'ExitOnForwardFailure=yes'
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
_extractPinggyUrl(output) {
|
|
229
|
+
const patterns = [
|
|
230
|
+
/https:\/\/[a-zA-Z0-9-]+\.a\.free\.pinggy\.link/,
|
|
231
|
+
/https:\/\/[a-zA-Z0-9-]+\.a\.pinggy\.io/,
|
|
232
|
+
/http:\/\/[a-zA-Z0-9-]+\.a\.free\.pinggy\.link/,
|
|
233
|
+
/http:\/\/[a-zA-Z0-9-]+\.a\.pinggy\.io/
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
for (const pattern of patterns) {
|
|
237
|
+
const match = output.match(pattern);
|
|
238
|
+
if (match) {
|
|
239
|
+
return match[0];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_extractSubdomain(url) {
|
|
247
|
+
if (!url) return null;
|
|
248
|
+
const patterns = [
|
|
249
|
+
/https?:\/\/([a-zA-Z0-9-]+)\.a\.free\.pinggy\.link/,
|
|
250
|
+
/https?:\/\/([a-zA-Z0-9-]+)\.a\.pinggy\.io/
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
for (const pattern of patterns) {
|
|
254
|
+
const match = url.match(pattern);
|
|
255
|
+
if (match) {
|
|
256
|
+
return match[1];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|