uplink-cli 0.1.0-alpha.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/README.md +205 -0
- package/cli/bin/uplink.js +55 -0
- package/cli/src/http.ts +60 -0
- package/cli/src/index.ts +32 -0
- package/cli/src/subcommands/admin.ts +351 -0
- package/cli/src/subcommands/db.ts +117 -0
- package/cli/src/subcommands/dev.ts +86 -0
- package/cli/src/subcommands/menu.ts +1222 -0
- package/cli/src/utils/port-scanner.ts +98 -0
- package/package.json +71 -0
- package/scripts/tunnel/client-improved.js +404 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import net from "net";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check if a port is in use by trying to connect to it
|
|
5
|
+
* More reliable than trying to bind to the port
|
|
6
|
+
*/
|
|
7
|
+
export async function isPortInUse(port: number): Promise<boolean> {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
// Try to connect to the port - if we can connect, something is listening
|
|
10
|
+
const socket = new net.Socket();
|
|
11
|
+
let resolved = false;
|
|
12
|
+
|
|
13
|
+
const cleanup = () => {
|
|
14
|
+
if (!resolved) {
|
|
15
|
+
resolved = true;
|
|
16
|
+
socket.destroy();
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
socket.setTimeout(500);
|
|
21
|
+
|
|
22
|
+
socket.once("connect", () => {
|
|
23
|
+
cleanup();
|
|
24
|
+
resolve(true); // Port is in use (we connected)
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
socket.once("timeout", () => {
|
|
28
|
+
cleanup();
|
|
29
|
+
resolve(false); // Port is not in use (timeout)
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
socket.once("error", (err: any) => {
|
|
33
|
+
// ECONNREFUSED means nothing is listening
|
|
34
|
+
if (err.code === "ECONNREFUSED") {
|
|
35
|
+
cleanup();
|
|
36
|
+
resolve(false);
|
|
37
|
+
} else {
|
|
38
|
+
cleanup();
|
|
39
|
+
resolve(true); // Other error might mean port is in use
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
socket.connect(port, "127.0.0.1");
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Scan common development ports to find active servers
|
|
49
|
+
*/
|
|
50
|
+
export async function scanCommonPorts(): Promise<number[]> {
|
|
51
|
+
const commonPorts = [
|
|
52
|
+
3000, 3001, 3002, 3003, // Common Node.js/React ports
|
|
53
|
+
8000, 8001, 8080, 8081, // Common web server ports
|
|
54
|
+
5000, 5001, 5002, // Common Flask/Python ports
|
|
55
|
+
4000, 4001, // Common API ports
|
|
56
|
+
5173, 5174, // Vite default ports
|
|
57
|
+
4200, // Angular default
|
|
58
|
+
9000, // Common dev ports
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const activePorts: number[] = [];
|
|
62
|
+
|
|
63
|
+
for (const port of commonPorts) {
|
|
64
|
+
if (await isPortInUse(port)) {
|
|
65
|
+
activePorts.push(port);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return activePorts;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Test if a port is actually serving HTTP
|
|
74
|
+
*/
|
|
75
|
+
export async function testHttpPort(port: number): Promise<boolean> {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const http = require("http");
|
|
78
|
+
const req = http.request(
|
|
79
|
+
{
|
|
80
|
+
hostname: "127.0.0.1",
|
|
81
|
+
port,
|
|
82
|
+
path: "/",
|
|
83
|
+
method: "HEAD",
|
|
84
|
+
timeout: 1000,
|
|
85
|
+
},
|
|
86
|
+
() => {
|
|
87
|
+
resolve(true);
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
req.on("error", () => resolve(false));
|
|
91
|
+
req.on("timeout", () => {
|
|
92
|
+
req.destroy();
|
|
93
|
+
resolve(false);
|
|
94
|
+
});
|
|
95
|
+
req.end();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "uplink-cli",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Localhost to public URL in seconds. No signup forms, no browser - everything in your terminal.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"tunnel",
|
|
7
|
+
"localhost",
|
|
8
|
+
"ngrok",
|
|
9
|
+
"expose",
|
|
10
|
+
"cli",
|
|
11
|
+
"dev-tools",
|
|
12
|
+
"webhook",
|
|
13
|
+
"public-url",
|
|
14
|
+
"port-forwarding"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/firstprinciplecode/uplink#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/firstprinciplecode/uplink.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/firstprinciplecode/uplink/issues"
|
|
23
|
+
},
|
|
24
|
+
"author": "First Principle Code",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20.0.0"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"uplink": "./cli/bin/uplink.js"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"cli/",
|
|
34
|
+
"scripts/tunnel/client-improved.js",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"dev:relay": "node scripts/tunnel/relay.js",
|
|
39
|
+
"dev:stub": "PORT=4100 AGENTCLOUD_TOKEN_DEV=dev-token node scripts/dev/stub-control-plane.js",
|
|
40
|
+
"dev:cli": "tsx cli/src/index.ts",
|
|
41
|
+
"dev:api": "tsx backend/src/server.ts",
|
|
42
|
+
"migrate": "tsx backend/scripts/migrate.ts",
|
|
43
|
+
"smoke:tunnel": "bash scripts/tunnel-smoke.sh",
|
|
44
|
+
"smoke:db": "bash scripts/db-api-smoke.sh",
|
|
45
|
+
"smoke:all": "bash scripts/smoke-all.sh",
|
|
46
|
+
"test:features": "bash scripts/test-new-features.sh",
|
|
47
|
+
"test:comprehensive": "bash scripts/test-comprehensive.sh"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"better-sqlite3": "^11.10.0",
|
|
51
|
+
"body-parser": "^1.20.3",
|
|
52
|
+
"commander": "^12.1.0",
|
|
53
|
+
"dotenv": "^16.6.1",
|
|
54
|
+
"express": "^4.19.2",
|
|
55
|
+
"express-rate-limit": "^8.2.1",
|
|
56
|
+
"helmet": "^8.1.0",
|
|
57
|
+
"node-fetch": "^2.7.0",
|
|
58
|
+
"pg": "^8.12.0",
|
|
59
|
+
"pino": "^10.1.0",
|
|
60
|
+
"pino-pretty": "^13.1.3",
|
|
61
|
+
"tsx": "^4.21.0",
|
|
62
|
+
"zod": "^4.2.1"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/express": "^4.17.21",
|
|
66
|
+
"@types/express-rate-limit": "^5.1.3",
|
|
67
|
+
"@types/node": "^22.7.4",
|
|
68
|
+
"@types/node-fetch": "^2.6.11",
|
|
69
|
+
"typescript": "^5.6.2"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Improved tunnel client with auto-reconnect, better error handling, and health checks.
|
|
4
|
+
* Usage: node scripts/tunnel/client-improved.js --token <token> --port 3000 --ctrl 127.0.0.1:7071
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const net = require("net");
|
|
8
|
+
const tls = require("tls");
|
|
9
|
+
const http = require("http");
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
|
|
12
|
+
// Configuration
|
|
13
|
+
const MAX_RECONNECT_DELAY = 30000; // 30 seconds max
|
|
14
|
+
const INITIAL_RECONNECT_DELAY = 1000; // Start with 1 second
|
|
15
|
+
const MAX_REQUEST_SIZE = 10 * 1024 * 1024; // 10MB max request body
|
|
16
|
+
const HEALTH_CHECK_INTERVAL = 30000; // Check local service every 30s
|
|
17
|
+
const REQUEST_TIMEOUT = 30000; // 30s timeout for local requests
|
|
18
|
+
|
|
19
|
+
function parseArgs() {
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const out = {};
|
|
22
|
+
for (let i = 0; i < args.length; i++) {
|
|
23
|
+
const a = args[i];
|
|
24
|
+
if (a === "--token") out.token = args[++i];
|
|
25
|
+
else if (a === "--port") out.port = Number(args[++i]);
|
|
26
|
+
else if (a === "--ctrl") out.ctrl = args[++i];
|
|
27
|
+
else if (a === "--max-size") out.maxSize = Number(args[++i]);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { token, port, ctrl, maxSize } = parseArgs();
|
|
33
|
+
if (!token || !port || !ctrl) {
|
|
34
|
+
console.error("Usage: node scripts/tunnel/client-improved.js --token <token> --port <port> --ctrl <host:port> [--max-size <bytes>]");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const [CTRL_HOST, CTRL_PORT] = ctrl.split(":");
|
|
39
|
+
const MAX_BODY_SIZE = maxSize || MAX_REQUEST_SIZE;
|
|
40
|
+
const CTRL_TLS_ENABLED = process.env.TUNNEL_CTRL_TLS === "true";
|
|
41
|
+
const CTRL_TLS_INSECURE = process.env.TUNNEL_CTRL_TLS_INSECURE === "true";
|
|
42
|
+
const CTRL_TLS_CA_PATH = process.env.TUNNEL_CTRL_CA || "";
|
|
43
|
+
const CTRL_TLS_CERT_PATH = process.env.TUNNEL_CTRL_CERT || "";
|
|
44
|
+
const CTRL_TLS_KEY_PATH = process.env.TUNNEL_CTRL_KEY || "";
|
|
45
|
+
|
|
46
|
+
function optionalRead(path) {
|
|
47
|
+
if (!path) return undefined;
|
|
48
|
+
try {
|
|
49
|
+
return fs.readFileSync(path);
|
|
50
|
+
} catch {
|
|
51
|
+
log("warn", `Could not read TLS file: ${path}`);
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// State
|
|
57
|
+
let socket = null;
|
|
58
|
+
let reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
59
|
+
let reconnectTimer = null;
|
|
60
|
+
let isConnected = false;
|
|
61
|
+
let isRegistered = false;
|
|
62
|
+
let healthCheckTimer = null;
|
|
63
|
+
let stats = {
|
|
64
|
+
requests: 0,
|
|
65
|
+
errors: 0,
|
|
66
|
+
reconnects: 0,
|
|
67
|
+
startTime: Date.now(),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function log(level, ...args) {
|
|
71
|
+
const prefix = {
|
|
72
|
+
info: "ℹ️",
|
|
73
|
+
error: "❌",
|
|
74
|
+
warn: "⚠️",
|
|
75
|
+
success: "✅",
|
|
76
|
+
}[level] || "•";
|
|
77
|
+
console.log(`${new Date().toISOString()} ${prefix}`, ...args);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function logError(err, context) {
|
|
81
|
+
const message = err.message || String(err);
|
|
82
|
+
const code = err.code || "";
|
|
83
|
+
log("error", context, message, code ? `(${code})` : "");
|
|
84
|
+
|
|
85
|
+
// Provide helpful error messages
|
|
86
|
+
if (code === "ECONNREFUSED") {
|
|
87
|
+
log("info", `Cannot connect to relay at ${CTRL_HOST}:${CTRL_PORT}. Is the relay running?`);
|
|
88
|
+
} else if (code === "ETIMEDOUT") {
|
|
89
|
+
log("info", `Connection timeout. Check network connectivity and firewall rules.`);
|
|
90
|
+
} else if (code === "ENOTFOUND") {
|
|
91
|
+
log("info", `Cannot resolve hostname "${CTRL_HOST}". Check DNS settings.`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkLocalService() {
|
|
96
|
+
const options = {
|
|
97
|
+
hostname: "127.0.0.1",
|
|
98
|
+
port,
|
|
99
|
+
path: "/",
|
|
100
|
+
method: "HEAD",
|
|
101
|
+
timeout: 2000,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const req = http.request(options, (res) => {
|
|
105
|
+
if (res.statusCode >= 200 && res.statusCode < 500) {
|
|
106
|
+
// Service is responding
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
log("warn", `Local service returned status ${res.statusCode}. Is it running correctly?`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
req.on("error", (err) => {
|
|
113
|
+
if (err.code === "ECONNREFUSED") {
|
|
114
|
+
log("error", `Local service on port ${port} is not responding. Is your app running?`);
|
|
115
|
+
} else {
|
|
116
|
+
log("warn", `Health check failed: ${err.message}`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
req.on("timeout", () => {
|
|
121
|
+
req.destroy();
|
|
122
|
+
log("warn", `Health check timeout. Local service may be slow or unresponsive.`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
req.end();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function connect() {
|
|
129
|
+
if (socket && !socket.destroyed) {
|
|
130
|
+
socket.destroy();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
log("info", `Connecting to relay at ${CTRL_HOST}:${CTRL_PORT}... ${CTRL_TLS_ENABLED ? "(TLS)" : "(plain)"}`);
|
|
134
|
+
|
|
135
|
+
if (CTRL_TLS_ENABLED) {
|
|
136
|
+
const ca = optionalRead(CTRL_TLS_CA_PATH);
|
|
137
|
+
const cert = optionalRead(CTRL_TLS_CERT_PATH);
|
|
138
|
+
const key = optionalRead(CTRL_TLS_KEY_PATH);
|
|
139
|
+
const options = {
|
|
140
|
+
host: CTRL_HOST,
|
|
141
|
+
port: Number(CTRL_PORT),
|
|
142
|
+
ca: ca ? [ca] : undefined,
|
|
143
|
+
cert,
|
|
144
|
+
key,
|
|
145
|
+
rejectUnauthorized: !CTRL_TLS_INSECURE,
|
|
146
|
+
servername: CTRL_HOST,
|
|
147
|
+
};
|
|
148
|
+
socket = tls.connect(options, () => {
|
|
149
|
+
isConnected = true;
|
|
150
|
+
reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
151
|
+
log("success", "Connected to relay (TLS)");
|
|
152
|
+
socket.setKeepAlive(true, 15000);
|
|
153
|
+
socket.write(JSON.stringify({ type: "register", token, targetPort: port }) + "\n");
|
|
154
|
+
});
|
|
155
|
+
} else {
|
|
156
|
+
socket = net.createConnection({ host: CTRL_HOST, port: Number(CTRL_PORT) }, () => {
|
|
157
|
+
isConnected = true;
|
|
158
|
+
reconnectDelay = INITIAL_RECONNECT_DELAY; // Reset delay on successful connection
|
|
159
|
+
log("success", "Connected to relay");
|
|
160
|
+
socket.setKeepAlive(true, 15000);
|
|
161
|
+
|
|
162
|
+
// Register with relay
|
|
163
|
+
socket.write(
|
|
164
|
+
JSON.stringify({ type: "register", token, targetPort: port }) + "\n"
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let buf = "";
|
|
170
|
+
socket.on("data", (chunk) => {
|
|
171
|
+
buf += chunk.toString("utf8");
|
|
172
|
+
let idx;
|
|
173
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
174
|
+
const line = buf.slice(0, idx);
|
|
175
|
+
buf = buf.slice(idx + 1);
|
|
176
|
+
if (!line.trim()) continue;
|
|
177
|
+
|
|
178
|
+
// Check message size
|
|
179
|
+
if (line.length > MAX_BODY_SIZE) {
|
|
180
|
+
log("error", `Message too large: ${line.length} bytes (max: ${MAX_BODY_SIZE})`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const msg = JSON.parse(line);
|
|
186
|
+
if (msg.type === "request") {
|
|
187
|
+
handleRequest(msg);
|
|
188
|
+
} else if (msg.type === "registered") {
|
|
189
|
+
isRegistered = true;
|
|
190
|
+
log("success", `Registered with relay (token: ${token.substring(0, 8)}...)`);
|
|
191
|
+
stats.reconnects = 0; // Reset reconnect count on successful registration
|
|
192
|
+
} else if (msg.type === "error") {
|
|
193
|
+
log("error", "Relay error:", msg.message || "Unknown error");
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
log("error", "Parse error:", err.message, `(message length: ${line.length})`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
socket.on("error", (err) => {
|
|
202
|
+
isConnected = false;
|
|
203
|
+
isRegistered = false;
|
|
204
|
+
logError(err, "Connection error");
|
|
205
|
+
scheduleReconnect();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
socket.on("close", () => {
|
|
209
|
+
const wasRegistered = isRegistered;
|
|
210
|
+
isConnected = false;
|
|
211
|
+
isRegistered = false;
|
|
212
|
+
|
|
213
|
+
if (wasRegistered) {
|
|
214
|
+
log("warn", "Connection closed. Attempting to reconnect...");
|
|
215
|
+
scheduleReconnect();
|
|
216
|
+
} else {
|
|
217
|
+
log("warn", "Connection closed before registration");
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function scheduleReconnect() {
|
|
223
|
+
if (reconnectTimer) {
|
|
224
|
+
clearTimeout(reconnectTimer);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
stats.reconnects++;
|
|
228
|
+
const delay = Math.min(reconnectDelay, MAX_RECONNECT_DELAY);
|
|
229
|
+
|
|
230
|
+
log("info", `Reconnecting in ${delay / 1000}s (attempt ${stats.reconnects})...`);
|
|
231
|
+
|
|
232
|
+
reconnectTimer = setTimeout(() => {
|
|
233
|
+
reconnectDelay *= 2; // Exponential backoff
|
|
234
|
+
connect();
|
|
235
|
+
}, delay);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function handleRequest(msg) {
|
|
239
|
+
stats.requests++;
|
|
240
|
+
|
|
241
|
+
// Validate request
|
|
242
|
+
if (!msg.id) {
|
|
243
|
+
log("error", "Received request without ID");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check body size
|
|
248
|
+
if (msg.body) {
|
|
249
|
+
const bodySize = Buffer.from(msg.body, "base64").length;
|
|
250
|
+
if (bodySize > MAX_BODY_SIZE) {
|
|
251
|
+
log("error", `Request body too large: ${bodySize} bytes (max: ${MAX_BODY_SIZE})`);
|
|
252
|
+
sendErrorResponse(msg.id, 413, "Request entity too large");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Clean headers - remove hop-by-hop headers and undefined values
|
|
258
|
+
const cleanHeaders = { ...msg.headers };
|
|
259
|
+
delete cleanHeaders.connection;
|
|
260
|
+
delete cleanHeaders["keep-alive"];
|
|
261
|
+
delete cleanHeaders["transfer-encoding"];
|
|
262
|
+
// Remove any undefined values
|
|
263
|
+
Object.keys(cleanHeaders).forEach(key => {
|
|
264
|
+
if (cleanHeaders[key] === undefined) {
|
|
265
|
+
delete cleanHeaders[key];
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const options = {
|
|
270
|
+
hostname: "127.0.0.1",
|
|
271
|
+
port,
|
|
272
|
+
path: msg.path || "/",
|
|
273
|
+
method: msg.method || "GET",
|
|
274
|
+
headers: cleanHeaders,
|
|
275
|
+
timeout: REQUEST_TIMEOUT,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const req = http.request(options, (resp) => {
|
|
279
|
+
const chunks = [];
|
|
280
|
+
let totalSize = 0;
|
|
281
|
+
|
|
282
|
+
resp.on("data", (d) => {
|
|
283
|
+
chunks.push(d);
|
|
284
|
+
totalSize += d.length;
|
|
285
|
+
|
|
286
|
+
// Check response size
|
|
287
|
+
if (totalSize > MAX_BODY_SIZE) {
|
|
288
|
+
req.destroy();
|
|
289
|
+
log("error", `Response too large: ${totalSize} bytes (max: ${MAX_BODY_SIZE})`);
|
|
290
|
+
sendErrorResponse(msg.id, 413, "Response entity too large");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
resp.on("end", () => {
|
|
296
|
+
const body = Buffer.concat(chunks);
|
|
297
|
+
const resMsg = {
|
|
298
|
+
type: "response",
|
|
299
|
+
id: msg.id,
|
|
300
|
+
status: resp.statusCode,
|
|
301
|
+
headers: resp.headers,
|
|
302
|
+
body: body.length ? body.toString("base64") : "",
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
if (socket && !socket.destroyed && isRegistered) {
|
|
306
|
+
socket.write(JSON.stringify(resMsg) + "\n");
|
|
307
|
+
} else {
|
|
308
|
+
log("warn", "Cannot send response: not connected");
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
req.on("error", (err) => {
|
|
314
|
+
stats.errors++;
|
|
315
|
+
logError(err, "Local request error");
|
|
316
|
+
|
|
317
|
+
const errorMsg = err.code === "ECONNREFUSED"
|
|
318
|
+
? "Local service not available"
|
|
319
|
+
: err.message;
|
|
320
|
+
|
|
321
|
+
sendErrorResponse(msg.id, 502, errorMsg);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
req.on("timeout", () => {
|
|
325
|
+
req.destroy();
|
|
326
|
+
stats.errors++;
|
|
327
|
+
log("error", `Request timeout after ${REQUEST_TIMEOUT}ms`);
|
|
328
|
+
sendErrorResponse(msg.id, 504, "Gateway timeout");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (msg.body) {
|
|
332
|
+
try {
|
|
333
|
+
req.write(Buffer.from(msg.body, "base64"));
|
|
334
|
+
} catch (err) {
|
|
335
|
+
log("error", "Error writing request body:", err.message);
|
|
336
|
+
sendErrorResponse(msg.id, 400, "Invalid request body");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
req.end();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function sendErrorResponse(id, status, message) {
|
|
345
|
+
if (!socket || socket.destroyed || !isRegistered) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const resMsg = {
|
|
350
|
+
type: "response",
|
|
351
|
+
id,
|
|
352
|
+
status,
|
|
353
|
+
headers: { "content-type": "text/plain" },
|
|
354
|
+
body: Buffer.from(message).toString("base64"),
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
socket.write(JSON.stringify(resMsg) + "\n");
|
|
359
|
+
} catch (err) {
|
|
360
|
+
log("error", "Failed to send error response:", err.message);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Graceful shutdown
|
|
365
|
+
function shutdown() {
|
|
366
|
+
log("info", "Shutting down...");
|
|
367
|
+
|
|
368
|
+
if (reconnectTimer) {
|
|
369
|
+
clearTimeout(reconnectTimer);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (healthCheckTimer) {
|
|
373
|
+
clearInterval(healthCheckTimer);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (socket && !socket.destroyed) {
|
|
377
|
+
socket.end();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Print stats
|
|
381
|
+
const uptime = Math.floor((Date.now() - stats.startTime) / 1000);
|
|
382
|
+
log("info", `Stats: ${stats.requests} requests, ${stats.errors} errors, ${stats.reconnects} reconnects, ${uptime}s uptime`);
|
|
383
|
+
|
|
384
|
+
process.exit(0);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Start connection
|
|
388
|
+
connect();
|
|
389
|
+
|
|
390
|
+
// Start health checks
|
|
391
|
+
healthCheckTimer = setInterval(checkLocalService, HEALTH_CHECK_INTERVAL);
|
|
392
|
+
checkLocalService(); // Immediate check
|
|
393
|
+
|
|
394
|
+
// Handle shutdown signals
|
|
395
|
+
process.on("SIGINT", shutdown);
|
|
396
|
+
process.on("SIGTERM", shutdown);
|
|
397
|
+
|
|
398
|
+
// Print connection info
|
|
399
|
+
log("info", `Tunnel client starting`);
|
|
400
|
+
log("info", `Token: ${token.substring(0, 8)}...`);
|
|
401
|
+
log("info", `Local port: ${port}`);
|
|
402
|
+
log("info", `Relay: ${CTRL_HOST}:${CTRL_PORT}`);
|
|
403
|
+
log("info", `Max request size: ${MAX_BODY_SIZE / 1024 / 1024}MB`);
|
|
404
|
+
|