cstunnel 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/bin/cs.js ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * cs — instant localhost tunnel
5
+ *
6
+ * Usage:
7
+ * cs --port 3000
8
+ * cs -p 8080
9
+ */
10
+
11
+ const { Command } = require('commander');
12
+ const { createTunnel } = require('../src/tunnel');
13
+
14
+ const program = new Command();
15
+
16
+ program
17
+ .name('cs')
18
+ .description('Instant localhost tunnel with automatic subdomain assignment')
19
+ .version('1.0.0')
20
+ .option('-p, --port <port>', 'local service port', '3000')
21
+ .option('-H, --host <host>', 'local service host', '127.0.0.1')
22
+ .option('--no-reconnect', 'disable auto reconnect on disconnect')
23
+ .parse(process.argv);
24
+
25
+ const options = program.opts();
26
+
27
+ // Start tunnel
28
+ createTunnel({
29
+ localPort: parseInt(options.port, 10),
30
+ localHost: options.host,
31
+ reconnect: options.reconnect !== false,
32
+ });
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "cstunnel",
3
+ "version": "1.0.0",
4
+ "description": "Instant localhost tunnel with automatic subdomain assignment",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "cs": "./bin/cs.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/"
12
+ ],
13
+ "keywords": ["tunnel", "ngrok", "localhost", "proxy", "webhook", "expose", "reverse-proxy"],
14
+ "license": "MIT",
15
+ "dependencies": {
16
+ "ws": "^8.18.0",
17
+ "commander": "^12.1.0"
18
+ },
19
+ "engines": {
20
+ "node": ">=16"
21
+ }
22
+ }
package/src/index.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * cstunnel — Programmatic API
3
+ *
4
+ * const cstunnel = require('cstunnel');
5
+ * cstunnel.createTunnel({ localPort: 3000 });
6
+ */
7
+
8
+ const { createTunnel } = require('./tunnel');
9
+
10
+ module.exports = { createTunnel };
package/src/tunnel.js ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * cstunnel core tunnel logic
3
+ */
4
+
5
+ const http = require('http');
6
+ const https = require('https');
7
+ const os = require('os');
8
+ const WebSocket = require('ws');
9
+
10
+ // ANSI color helpers
11
+ const c = {
12
+ reset: '\x1b[0m',
13
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
14
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
15
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
16
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
17
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
18
+ };
19
+
20
+ /**
21
+ * Get a unique device fingerprint: hostname + first non-internal MAC address hash.
22
+ * Cross-platform, no external dependencies.
23
+ */
24
+ function getDeviceId() {
25
+ const parts = [os.hostname()];
26
+ const interfaces = os.networkInterfaces();
27
+ for (const name of Object.keys(interfaces)) {
28
+ for (const iface of interfaces[name]) {
29
+ if (!iface.internal && iface.mac !== '00:00:00:00:00:00') {
30
+ parts.push(iface.mac);
31
+ break; // grab the first non-internal NIC MAC
32
+ }
33
+ }
34
+ }
35
+ return parts.join('_').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || os.hostname();
36
+ }
37
+
38
+ const SERVER_URL = 'wss://app.cloudstudio.site';
39
+
40
+ /** Create tunnel connection */
41
+ function createTunnel({ localPort, localHost, reconnect }) {
42
+ let ws = null;
43
+ let heartbeatTimer = null;
44
+ let reconnectTimer = null;
45
+ let reconnectAttempts = 0;
46
+ const MAX_RECONNECT_DELAY = 30_000;
47
+ const deviceId = getDeviceId();
48
+ const deviceHostname = os.hostname();
49
+ const devicePlatform = `${os.platform()} ${os.release()} ${os.arch()}`;
50
+
51
+ // ---- Pretty output ----
52
+ function banner(subdomain, publicUrl) {
53
+ console.log('');
54
+ console.log(c.green(' Tunnel established!'));
55
+ console.log('');
56
+ console.log(
57
+ ` ${c.dim('Local:')} ${c.cyan(`http://${localHost}:${localPort}`)}`
58
+ );
59
+ console.log(
60
+ ` ${c.dim('Public:')} ${c.cyan(publicUrl)}`
61
+ );
62
+ console.log('');
63
+ console.log(c.dim(' Ctrl+C to disconnect'));
64
+ console.log('');
65
+ }
66
+
67
+ // ---- Connect to server ----
68
+ function connect() {
69
+ // URL params: port + mac (device fingerprint)
70
+ const wsUrl = `${SERVER_URL}?port=${localPort}&mac=${encodeURIComponent(deviceId)}`;
71
+
72
+ console.log(c.dim(` Connecting to ${SERVER_URL} ...`));
73
+
74
+ ws = new WebSocket(wsUrl);
75
+
76
+ ws.on('open', () => {
77
+ reconnectAttempts = 0;
78
+ console.log(c.dim(' WebSocket connected, waiting for tunnel assignment...'));
79
+ });
80
+
81
+ ws.on('message', (raw) => {
82
+ let msg;
83
+ try {
84
+ msg = JSON.parse(raw.toString());
85
+ } catch {
86
+ return;
87
+ }
88
+
89
+ switch (msg.type) {
90
+ case 'connected': {
91
+ const { subdomain, publicUrl } = msg;
92
+ banner(subdomain, publicUrl);
93
+
94
+ // report device info
95
+ if (ws.readyState === WebSocket.OPEN) {
96
+ ws.send(JSON.stringify({
97
+ type: 'device_info',
98
+ mac: deviceId,
99
+ hostname: deviceHostname,
100
+ platform: devicePlatform,
101
+ }));
102
+ }
103
+
104
+ // start heartbeat
105
+ heartbeatTimer = setInterval(() => {
106
+ if (ws.readyState === WebSocket.OPEN) {
107
+ ws.send(JSON.stringify({ type: 'pong' }));
108
+ }
109
+ }, 25_000);
110
+ break;
111
+ }
112
+
113
+ case 'ping': {
114
+ if (ws.readyState === WebSocket.OPEN) {
115
+ ws.send(JSON.stringify({ type: 'pong' }));
116
+ }
117
+ break;
118
+ }
119
+
120
+ case 'rejected': {
121
+ console.log('');
122
+ console.log(c.red(` Connection rejected: ${msg.reason || 'unknown reason'}`));
123
+ console.log(c.dim(` Device ID: ${msg.mac || deviceId}`));
124
+ console.log('');
125
+ process.exit(1);
126
+ }
127
+
128
+ case 'kicked': {
129
+ console.log('');
130
+ console.log(c.yellow(` Kicked by admin: ${msg.reason || 'no reason given'}`));
131
+ console.log('');
132
+ process.exit(1);
133
+ }
134
+
135
+ case 'request': {
136
+ handleRequest(msg, ws);
137
+ break;
138
+ }
139
+
140
+ default:
141
+ break;
142
+ }
143
+ });
144
+
145
+ ws.on('close', (code) => {
146
+ clearInterval(heartbeatTimer);
147
+ console.log(c.yellow(` Disconnected (code: ${code})`));
148
+
149
+ if (reconnect) {
150
+ scheduleReconnect();
151
+ } else {
152
+ console.log(c.red(' Disconnected, not reconnecting'));
153
+ process.exit(1);
154
+ }
155
+ });
156
+
157
+ ws.on('error', (err) => {
158
+ console.error(c.red(` WebSocket error: ${err.message}`));
159
+ });
160
+ }
161
+
162
+ // ---- Reconnect logic ----
163
+ function scheduleReconnect() {
164
+ const delay = Math.min(1000 * 2 ** reconnectAttempts, MAX_RECONNECT_DELAY);
165
+ reconnectAttempts++;
166
+ console.log(c.yellow(` Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt #${reconnectAttempts})...`));
167
+ reconnectTimer = setTimeout(connect, delay);
168
+ }
169
+
170
+ // ---- Forward HTTP requests to local ----
171
+ function handleRequest(msg, wsConn) {
172
+ const { id, method, path, headers, body, bodyEncoding } = msg;
173
+
174
+ const cleanHeaders = { ...headers };
175
+ delete cleanHeaders['host'];
176
+ delete cleanHeaders['connection'];
177
+ delete cleanHeaders['keep-alive'];
178
+ delete cleanHeaders['transfer-encoding'];
179
+ delete cleanHeaders['x-forwarded-for'];
180
+ delete cleanHeaders['x-real-ip'];
181
+ if (headers['x-forwarded-for'] || headers['x-real-ip']) {
182
+ cleanHeaders['x-forwarded-for'] = headers['x-forwarded-for'] || headers['x-real-ip'];
183
+ }
184
+
185
+ const options = {
186
+ hostname: localHost,
187
+ port: localPort,
188
+ path: path || '/',
189
+ method: method || 'GET',
190
+ headers: cleanHeaders,
191
+ timeout: 25_000,
192
+ };
193
+
194
+ const connector = localPort === 443 ? https : http;
195
+ const localReq = connector.request(options, (localRes) => {
196
+ const chunks = [];
197
+ localRes.on('data', (chunk) => chunks.push(chunk));
198
+ localRes.on('end', () => {
199
+ const responseBody = Buffer.concat(chunks);
200
+ const isText =
201
+ (localRes.headers['content-type'] || '').includes('text') ||
202
+ (localRes.headers['content-type'] || '').includes('json') ||
203
+ (localRes.headers['content-type'] || '').includes('javascript') ||
204
+ (localRes.headers['content-type'] || '').includes('xml');
205
+
206
+ wsConn.send(
207
+ JSON.stringify({
208
+ type: 'response',
209
+ id,
210
+ status: localRes.statusCode,
211
+ headers: localRes.headers,
212
+ body: responseBody.toString(isText ? 'utf-8' : 'base64'),
213
+ bodyEncoding: isText ? 'utf-8' : 'base64',
214
+ })
215
+ );
216
+ });
217
+ });
218
+
219
+ localReq.on('timeout', () => {
220
+ localReq.destroy();
221
+ wsConn.send(JSON.stringify({ type: 'response', id, status: 504, body: 'Local service timeout' }));
222
+ });
223
+
224
+ localReq.on('error', (err) => {
225
+ wsConn.send(JSON.stringify({ type: 'response', id, status: 502, body: `Local service error: ${err.message}` }));
226
+ });
227
+
228
+ if (body) {
229
+ localReq.write(Buffer.from(body, bodyEncoding || 'utf-8'));
230
+ }
231
+ localReq.end();
232
+ }
233
+
234
+ // ---- Cleanup ----
235
+ function cleanup() {
236
+ clearInterval(heartbeatTimer);
237
+ clearTimeout(reconnectTimer);
238
+ if (ws) ws.close();
239
+ }
240
+
241
+ process.on('SIGINT', () => {
242
+ cleanup();
243
+ console.log(c.dim('\n Disconnected'));
244
+ process.exit(0);
245
+ });
246
+
247
+ process.on('SIGTERM', () => {
248
+ cleanup();
249
+ process.exit(0);
250
+ });
251
+
252
+ connect();
253
+ }
254
+
255
+ module.exports = { createTunnel };