@o-town/expose 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/.vscode/settings.json +1 -0
- package/README.md +105 -0
- package/expose.js +190 -0
- package/package.json +13 -0
- package/server.js +197 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# expose
|
|
2
|
+
|
|
3
|
+
> Zero-dependency tunnel — expose `localhost` to `*.site.com` using only Node's built-in `net` module.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
http://localhost:3000 → https://abc1f3.site.com
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Install & run (client)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Run directly with npx (no install needed)
|
|
15
|
+
npx expose 3000
|
|
16
|
+
|
|
17
|
+
# Or install globally
|
|
18
|
+
npm install -g expose
|
|
19
|
+
expose 3000
|
|
20
|
+
|
|
21
|
+
# With a custom subdomain
|
|
22
|
+
expose 3000 myapp
|
|
23
|
+
# → https://myapp.site.com
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Environment variables
|
|
27
|
+
|
|
28
|
+
| Variable | Default | Description |
|
|
29
|
+
|-----------------|------------------|------------------------------|
|
|
30
|
+
| `EXPOSE_SERVER` | `tunnel.site.com`| Tunnel server hostname |
|
|
31
|
+
| `EXPOSE_PORT` | `2000` | Tunnel server TCP port |
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Deploy the server on site.com
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
node server.js --http-port 80 --tunnel-port 2000 --domain site.com
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Flags / env vars
|
|
42
|
+
|
|
43
|
+
| Flag | Env var | Default |
|
|
44
|
+
|------------------|---------------|-------------|
|
|
45
|
+
| `--http-port` | `HTTP_PORT` | `80` |
|
|
46
|
+
| `--tunnel-port` | `TUNNEL_PORT` | `2000` |
|
|
47
|
+
| `--domain` | `DOMAIN` | `site.com` |
|
|
48
|
+
|
|
49
|
+
### Keep alive with PM2
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm i -g pm2
|
|
53
|
+
pm2 start server.js --name expose-server -- --http-port 80 --domain site.com
|
|
54
|
+
pm2 save && pm2 startup
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### nginx + TLS (recommended)
|
|
58
|
+
|
|
59
|
+
Issue a wildcard cert first:
|
|
60
|
+
```bash
|
|
61
|
+
certbot certonly --manual --preferred-challenges dns -d "*.site.com" -d "site.com"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then add a site config:
|
|
65
|
+
```nginx
|
|
66
|
+
server {
|
|
67
|
+
listen 443 ssl;
|
|
68
|
+
server_name *.site.com;
|
|
69
|
+
|
|
70
|
+
ssl_certificate /etc/letsencrypt/live/site.com/fullchain.pem;
|
|
71
|
+
ssl_certificate_key /etc/letsencrypt/live/site.com/privkey.pem;
|
|
72
|
+
|
|
73
|
+
location / {
|
|
74
|
+
proxy_pass http://127.0.0.1:80;
|
|
75
|
+
proxy_set_header Host $host;
|
|
76
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## How it works
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
Browser
|
|
87
|
+
│ HTTPS → nginx → port 80
|
|
88
|
+
▼
|
|
89
|
+
server.js (http.Server — public)
|
|
90
|
+
│ JSON line over TCP socket (port 2000)
|
|
91
|
+
▼
|
|
92
|
+
expose.js (net.createConnection — your machine)
|
|
93
|
+
│ http.request
|
|
94
|
+
▼
|
|
95
|
+
localhost:3000
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
1. **Client** opens a raw TCP socket to `site.com:2000` and sends a `register` message.
|
|
99
|
+
2. **Server** assigns a subdomain and replies `ready` with the public URL.
|
|
100
|
+
3. **Browser** hits `https://abc1f3.site.com` → nginx → server's HTTP listener.
|
|
101
|
+
4. **Server** sends a `request` JSON line over the TCP socket.
|
|
102
|
+
5. **Client** forwards it to `localhost:PORT`, gets the response, sends `response` back.
|
|
103
|
+
6. **Server** writes the response to the waiting browser connection.
|
|
104
|
+
|
|
105
|
+
Zero dependencies. No WebSocket. No npm installs. Pure Node.
|
package/expose.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* expose — tunnel client
|
|
6
|
+
* Usage: node expose.js <port> [subdomain]
|
|
7
|
+
* npx expose 3000
|
|
8
|
+
* npx expose 3000 myapp
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const net = require('net');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
|
|
14
|
+
// ── CLI args ─────────────────────────────────────────────────────────────────
|
|
15
|
+
const [,, rawPort, subdomain] = process.argv;
|
|
16
|
+
|
|
17
|
+
if (!rawPort || isNaN(Number(rawPort))) {
|
|
18
|
+
console.error('\n Usage: expose <port> [subdomain]\n Example: expose 3000\n');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const LOCAL_PORT = Number(rawPort);
|
|
23
|
+
const SERVER_HOST = process.env.EXPOSE_SERVER || 'tunnel.site.com';
|
|
24
|
+
const SERVER_PORT = Number(process.env.EXPOSE_PORT || 2000);
|
|
25
|
+
|
|
26
|
+
// ── Colours (no deps) ────────────────────────────────────────────────────────
|
|
27
|
+
const c = {
|
|
28
|
+
reset: '\x1b[0m',
|
|
29
|
+
bold: '\x1b[1m',
|
|
30
|
+
dim: '\x1b[2m',
|
|
31
|
+
green: '\x1b[32m',
|
|
32
|
+
cyan: '\x1b[36m',
|
|
33
|
+
yellow: '\x1b[33m',
|
|
34
|
+
red: '\x1b[31m',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function log(msg) { process.stdout.write(msg + '\n'); }
|
|
38
|
+
function dim(msg) { log(c.dim + msg + c.reset); }
|
|
39
|
+
function ok(msg) { log(c.green + ' ✔ ' + c.reset + msg); }
|
|
40
|
+
function warn(msg) { log(c.yellow + ' ⚠ ' + c.reset + msg); }
|
|
41
|
+
function err(msg) { log(c.red + ' ✖ ' + c.reset + msg); }
|
|
42
|
+
|
|
43
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
44
|
+
let reconnectDelay = 1000;
|
|
45
|
+
let connected = false;
|
|
46
|
+
|
|
47
|
+
// ── Main connection loop ─────────────────────────────────────────────────────
|
|
48
|
+
function connect() {
|
|
49
|
+
dim(` Connecting to ${SERVER_HOST}:${SERVER_PORT}…`);
|
|
50
|
+
|
|
51
|
+
const sock = net.createConnection({ host: SERVER_HOST, port: SERVER_PORT });
|
|
52
|
+
let lineBuffer = '';
|
|
53
|
+
|
|
54
|
+
// ── Connected ──────────────────────────────────────────────────────────────
|
|
55
|
+
sock.once('connect', () => {
|
|
56
|
+
reconnectDelay = 1000; // reset back-off on success
|
|
57
|
+
send(sock, {
|
|
58
|
+
type: 'register',
|
|
59
|
+
port: LOCAL_PORT,
|
|
60
|
+
subdomain: subdomain || null,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── Incoming messages ──────────────────────────────────────────────────────
|
|
65
|
+
sock.on('data', (chunk) => {
|
|
66
|
+
lineBuffer += chunk.toString();
|
|
67
|
+
const lines = lineBuffer.split('\n');
|
|
68
|
+
lineBuffer = lines.pop(); // keep incomplete tail
|
|
69
|
+
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
if (!line.trim()) continue;
|
|
72
|
+
let msg;
|
|
73
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
74
|
+
handleMessage(sock, msg);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── Disconnect / error ─────────────────────────────────────────────────────
|
|
79
|
+
sock.on('close', () => {
|
|
80
|
+
if (connected) {
|
|
81
|
+
warn('Tunnel disconnected.');
|
|
82
|
+
connected = false;
|
|
83
|
+
}
|
|
84
|
+
scheduleReconnect();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
sock.on('error', (e) => {
|
|
88
|
+
err(`Socket error: ${e.message}`);
|
|
89
|
+
sock.destroy();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function scheduleReconnect() {
|
|
94
|
+
warn(`Reconnecting in ${reconnectDelay / 1000}s…\n`);
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
|
|
97
|
+
connect();
|
|
98
|
+
}, reconnectDelay);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Message handler ──────────────────────────────────────────────────────────
|
|
102
|
+
function handleMessage(sock, msg) {
|
|
103
|
+
switch (msg.type) {
|
|
104
|
+
case 'ready':
|
|
105
|
+
connected = true;
|
|
106
|
+
log('');
|
|
107
|
+
ok(`Tunnel open!\n`);
|
|
108
|
+
log(` ${c.bold}Local :${c.reset} ${c.cyan}http://localhost:${LOCAL_PORT}${c.reset}`);
|
|
109
|
+
log(` ${c.bold}Public :${c.reset} ${c.cyan}${msg.url}${c.reset}`);
|
|
110
|
+
log(c.dim + '\n Ctrl+C to stop.\n' + c.reset);
|
|
111
|
+
break;
|
|
112
|
+
|
|
113
|
+
case 'request':
|
|
114
|
+
forwardRequest(msg, (response) => {
|
|
115
|
+
send(sock, { type: 'response', id: msg.id, ...response });
|
|
116
|
+
});
|
|
117
|
+
break;
|
|
118
|
+
|
|
119
|
+
case 'error':
|
|
120
|
+
err(`Server: ${msg.message}`);
|
|
121
|
+
break;
|
|
122
|
+
|
|
123
|
+
default:
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Forward HTTP request to local server ─────────────────────────────────────
|
|
129
|
+
function forwardRequest({ method, path, headers, body }, cb) {
|
|
130
|
+
// Strip hop-by-hop headers that confuse Node's http module
|
|
131
|
+
const safeHeaders = Object.fromEntries(
|
|
132
|
+
Object.entries(headers || {}).filter(([k]) =>
|
|
133
|
+
!['connection', 'keep-alive', 'transfer-encoding', 'upgrade'].includes(k.toLowerCase())
|
|
134
|
+
)
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const options = {
|
|
138
|
+
hostname: 'localhost',
|
|
139
|
+
port: LOCAL_PORT,
|
|
140
|
+
method: method || 'GET',
|
|
141
|
+
path: path || '/',
|
|
142
|
+
headers: { ...safeHeaders, host: `localhost:${LOCAL_PORT}` },
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const req = http.request(options, (res) => {
|
|
146
|
+
const chunks = [];
|
|
147
|
+
res.on('data', (c) => chunks.push(c));
|
|
148
|
+
res.on('end', () => {
|
|
149
|
+
cb({
|
|
150
|
+
status: res.statusCode,
|
|
151
|
+
headers: res.headers,
|
|
152
|
+
body: Buffer.concat(chunks).toString('base64'),
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
req.setTimeout(15_000, () => {
|
|
158
|
+
req.destroy();
|
|
159
|
+
cb({ status: 504, headers: {}, body: btoa('Gateway Timeout') });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
req.on('error', (e) => {
|
|
163
|
+
err(`Local server error: ${e.message}`);
|
|
164
|
+
cb({
|
|
165
|
+
status: 502,
|
|
166
|
+
headers: { 'content-type': 'text/plain' },
|
|
167
|
+
body: Buffer.from(`Local error: ${e.message}`).toString('base64'),
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (body) req.write(Buffer.from(body, 'base64'));
|
|
172
|
+
req.end();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
176
|
+
function send(sock, obj) {
|
|
177
|
+
if (sock.writable) sock.write(JSON.stringify(obj) + '\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function btoa(str) { return Buffer.from(str).toString('base64'); }
|
|
181
|
+
|
|
182
|
+
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
183
|
+
process.on('SIGINT', () => { log('\n Bye!\n'); process.exit(0); });
|
|
184
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
185
|
+
|
|
186
|
+
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
187
|
+
log('');
|
|
188
|
+
log(` ${c.bold}expose${c.reset} v1.0.0 — localhost:${LOCAL_PORT} → *.site.com`);
|
|
189
|
+
log('');
|
|
190
|
+
connect();
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@o-town/expose",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Expose localhost to *.site.com — zero dependencies, pure Node net",
|
|
5
|
+
"bin": { "expose": "./expose.js" },
|
|
6
|
+
"scripts": {
|
|
7
|
+
"client": "node expose.js",
|
|
8
|
+
"server": "node server.js"
|
|
9
|
+
},
|
|
10
|
+
"preferGlobal": true,
|
|
11
|
+
"engines": { "node": ">=14" },
|
|
12
|
+
"license": "MIT"
|
|
13
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* expose — tunnel server
|
|
6
|
+
* Deploy this on your VPS / site.com machine.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node server.js
|
|
10
|
+
* node server.js --http-port 80 --tunnel-port 2000 --domain site.com
|
|
11
|
+
*
|
|
12
|
+
* Environment variables (alternative to flags):
|
|
13
|
+
* HTTP_PORT public HTTP port (default 80)
|
|
14
|
+
* TUNNEL_PORT client TCP port (default 2000)
|
|
15
|
+
* DOMAIN base domain (default site.com)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const net = require('net');
|
|
19
|
+
const http = require('http');
|
|
20
|
+
const { randomBytes } = require('crypto');
|
|
21
|
+
|
|
22
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
23
|
+
const args = parseArgs(process.argv.slice(2));
|
|
24
|
+
const HTTP_PORT = Number(args['http-port'] || process.env.HTTP_PORT || 80);
|
|
25
|
+
const TUNNEL_PORT= Number(args['tunnel-port'] || process.env.TUNNEL_PORT || 2000);
|
|
26
|
+
const DOMAIN = args['domain'] || process.env.DOMAIN || 'site.com';
|
|
27
|
+
|
|
28
|
+
// ── State ─────────────────────────────────────────────────────────────────────
|
|
29
|
+
/**
|
|
30
|
+
* clients : Map<subdomain, {
|
|
31
|
+
* sock : net.Socket,
|
|
32
|
+
* pending : Map<id, { res: http.ServerResponse, timer: NodeJS.Timeout }>
|
|
33
|
+
* }>
|
|
34
|
+
*/
|
|
35
|
+
const clients = new Map();
|
|
36
|
+
|
|
37
|
+
// ── Tunnel server (TCP — clients connect here) ────────────────────────────────
|
|
38
|
+
const tunnelServer = net.createServer((sock) => {
|
|
39
|
+
let lineBuffer = '';
|
|
40
|
+
let subdomain = null;
|
|
41
|
+
|
|
42
|
+
sock.on('data', (chunk) => {
|
|
43
|
+
lineBuffer += chunk.toString();
|
|
44
|
+
const lines = lineBuffer.split('\n');
|
|
45
|
+
lineBuffer = lines.pop();
|
|
46
|
+
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
if (!line.trim()) continue;
|
|
49
|
+
let msg;
|
|
50
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
51
|
+
handleClientMessage(sock, msg);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
sock.on('close', () => {
|
|
56
|
+
if (subdomain && clients.get(subdomain)?.sock === sock) {
|
|
57
|
+
clients.delete(subdomain);
|
|
58
|
+
log(`[-] Tunnel closed: ${subdomain}.${DOMAIN}`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
sock.on('error', (e) => {
|
|
63
|
+
if (e.code !== 'ECONNRESET') log(`[!] Client socket error: ${e.message}`);
|
|
64
|
+
sock.destroy();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ── Handle messages from client ──────────────────────────────────────────────
|
|
68
|
+
function handleClientMessage(sock, msg) {
|
|
69
|
+
switch (msg.type) {
|
|
70
|
+
|
|
71
|
+
// Client registers a new tunnel
|
|
72
|
+
case 'register': {
|
|
73
|
+
const desired = msg.subdomain;
|
|
74
|
+
subdomain = (desired && !clients.has(desired))
|
|
75
|
+
? desired
|
|
76
|
+
: randomBytes(4).toString('hex');
|
|
77
|
+
|
|
78
|
+
clients.set(subdomain, { sock, pending: new Map() });
|
|
79
|
+
|
|
80
|
+
const url = `https://${subdomain}.${DOMAIN}`;
|
|
81
|
+
log(`[+] Tunnel opened: ${url} (port=${msg.port})`);
|
|
82
|
+
send(sock, { type: 'ready', url, subdomain });
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Client sends back an HTTP response
|
|
87
|
+
case 'response': {
|
|
88
|
+
const client = clients.get(subdomain);
|
|
89
|
+
if (!client) break;
|
|
90
|
+
|
|
91
|
+
const pending = client.pending.get(msg.id);
|
|
92
|
+
if (!pending) break;
|
|
93
|
+
|
|
94
|
+
clearTimeout(pending.timer);
|
|
95
|
+
client.pending.delete(msg.id);
|
|
96
|
+
|
|
97
|
+
const body = msg.body
|
|
98
|
+
? Buffer.from(msg.body, 'base64')
|
|
99
|
+
: Buffer.alloc(0);
|
|
100
|
+
|
|
101
|
+
// Strip hop-by-hop headers
|
|
102
|
+
const headers = Object.fromEntries(
|
|
103
|
+
Object.entries(msg.headers || {}).filter(([k]) =>
|
|
104
|
+
!['transfer-encoding', 'connection', 'keep-alive'].includes(k.toLowerCase())
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
pending.res.writeHead(msg.status || 200, headers);
|
|
109
|
+
pending.res.end(body);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
default:
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
tunnelServer.listen(TUNNEL_PORT, () => {
|
|
120
|
+
log(`[expose-server] Tunnel listener on :${TUNNEL_PORT}`);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── HTTP server (public-facing) ───────────────────────────────────────────────
|
|
124
|
+
const httpServer = http.createServer((req, res) => {
|
|
125
|
+
// Derive subdomain from Host header
|
|
126
|
+
const host = (req.headers.host || '').split(':')[0]; // strip port
|
|
127
|
+
const sub = host.replace(`.${DOMAIN}`, '').split('.').pop();
|
|
128
|
+
const client = clients.get(sub);
|
|
129
|
+
|
|
130
|
+
if (!client) {
|
|
131
|
+
res.writeHead(502, { 'content-type': 'text/html' });
|
|
132
|
+
res.end(`<h2>No tunnel active for <code>${host}</code></h2>`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const id = randomBytes(8).toString('hex');
|
|
137
|
+
const chunks = [];
|
|
138
|
+
|
|
139
|
+
req.on('data', (c) => chunks.push(c));
|
|
140
|
+
req.on('end', () => {
|
|
141
|
+
const body = Buffer.concat(chunks).toString('base64');
|
|
142
|
+
|
|
143
|
+
// Forward request to client
|
|
144
|
+
send(client.sock, {
|
|
145
|
+
type: 'request',
|
|
146
|
+
id,
|
|
147
|
+
method: req.method,
|
|
148
|
+
path: req.url,
|
|
149
|
+
headers: req.headers,
|
|
150
|
+
body,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Timeout if client doesn't respond
|
|
154
|
+
const timer = setTimeout(() => {
|
|
155
|
+
client.pending.delete(id);
|
|
156
|
+
if (!res.headersSent) {
|
|
157
|
+
res.writeHead(504, { 'content-type': 'text/plain' });
|
|
158
|
+
res.end('Gateway Timeout — local server did not respond.');
|
|
159
|
+
}
|
|
160
|
+
}, 30_000);
|
|
161
|
+
|
|
162
|
+
client.pending.set(id, { res, timer });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
req.on('error', () => {
|
|
166
|
+
res.writeHead(400); res.end();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
httpServer.listen(HTTP_PORT, () => {
|
|
171
|
+
log(`[expose-server] HTTP listener on :${HTTP_PORT} (domain: *.${DOMAIN})`);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
175
|
+
function send(sock, obj) {
|
|
176
|
+
if (sock.writable) sock.write(JSON.stringify(obj) + '\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function log(msg) { console.log(`[${new Date().toISOString()}] ${msg}`); }
|
|
180
|
+
|
|
181
|
+
function parseArgs(argv) {
|
|
182
|
+
const out = {};
|
|
183
|
+
for (let i = 0; i < argv.length; i++) {
|
|
184
|
+
if (argv[i].startsWith('--')) out[argv[i].slice(2)] = argv[i + 1] ?? true;
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
190
|
+
function shutdown() {
|
|
191
|
+
log('Shutting down…');
|
|
192
|
+
tunnelServer.close();
|
|
193
|
+
httpServer.close();
|
|
194
|
+
process.exit(0);
|
|
195
|
+
}
|
|
196
|
+
process.on('SIGINT', shutdown);
|
|
197
|
+
process.on('SIGTERM', shutdown);
|