@remotelinker/reverse-ws-tunnel 1.0.4
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/CHANGELOG.md +54 -0
- package/README.md +406 -0
- package/client/index.js +34 -0
- package/client/proxyServer.js +74 -0
- package/client/tunnelClient.js +196 -0
- package/client/utils.js +24 -0
- package/cookbook.md +595 -0
- package/package.json +47 -0
- package/server/constants.js +7 -0
- package/server/index.js +8 -0
- package/server/messageHandler.js +79 -0
- package/server/state.js +1 -0
- package/server/tcpServer.js +137 -0
- package/server/websocketServer.js +142 -0
- package/utils/index.js +8 -0
- package/utils/loadConfig.js +54 -0
- package/utils/logger.js +136 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const state = require('./state');
|
|
2
|
+
const { MESSAGE_TYPE_CONFIG, MESSAGE_TYPE_DATA } = require('./constants');
|
|
3
|
+
const { startTCPServer } = require('./tcpServer');
|
|
4
|
+
const { logger } = require('../utils/logger');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Handles a parsed WebSocket message.
|
|
8
|
+
* @param {WebSocket} ws - The WebSocket connection.
|
|
9
|
+
* @param {string} tunnelId - Tunnel identifier.
|
|
10
|
+
* @param {string} uuid - Unique identifier for TCP connection.
|
|
11
|
+
* @param {number} type - Message type (config or data).
|
|
12
|
+
* @param {Buffer} payload - Data payload.
|
|
13
|
+
* @param {string} tunnelIdHeaderName - Header name to identify the tunnel.
|
|
14
|
+
* @param {number} port - Listening port for state grouping.
|
|
15
|
+
*/
|
|
16
|
+
function handleParsedMessage(ws, tunnelId, uuid, type, payload, tunnelIdHeaderName, port) {
|
|
17
|
+
logger.trace(`handleParsedMessage called. type=${type}, tunnelId=${tunnelId}, uuid=${uuid}`);
|
|
18
|
+
|
|
19
|
+
if (type === MESSAGE_TYPE_CONFIG) {
|
|
20
|
+
try {
|
|
21
|
+
const config = JSON.parse(payload);
|
|
22
|
+
logger.debug(`Received tunnel config for tunnelId=${tunnelId}: ${JSON.stringify(config)}`);
|
|
23
|
+
|
|
24
|
+
const { TUNNEL_ENTRY_PORT } = config;
|
|
25
|
+
|
|
26
|
+
if (!TUNNEL_ENTRY_PORT) {
|
|
27
|
+
logger.warn(`Tunnel config missing TUNNEL_ENTRY_PORT for tunnelId=${tunnelId}`);
|
|
28
|
+
throw new Error('Missing tunnel entry port!');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
logger.debug(`Registering WebSocket tunnel [${tunnelId}] on port ${port}`);
|
|
32
|
+
|
|
33
|
+
if (!state[port]) {
|
|
34
|
+
state[port] = {
|
|
35
|
+
websocketTunnels: {},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!state[port].websocketTunnels) {
|
|
40
|
+
state[port].websocketTunnels = {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
state[port].websocketTunnels[tunnelId] = {
|
|
44
|
+
ws,
|
|
45
|
+
tcpConnections: {},
|
|
46
|
+
httpConnections: {},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const portKey = String(TUNNEL_ENTRY_PORT);
|
|
50
|
+
if (!state[port][portKey]) {
|
|
51
|
+
logger.info(`Starting new TCP server on port ${TUNNEL_ENTRY_PORT} for tunnelId=${tunnelId}`);
|
|
52
|
+
state[port][portKey] = {};
|
|
53
|
+
state[port][portKey] = {
|
|
54
|
+
tcpServer: startTCPServer(TUNNEL_ENTRY_PORT, tunnelIdHeaderName, port),
|
|
55
|
+
};
|
|
56
|
+
} else {
|
|
57
|
+
logger.debug(`TCP server already exists on port ${TUNNEL_ENTRY_PORT}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
logger.info(`Tunnel [${tunnelId}] established successfully`);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
logger.error(`Failed to process MESSAGE_TYPE_CONFIG for tunnelId=${tunnelId}: ${error.message}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Handle MESSAGE_TYPE_DATA
|
|
69
|
+
const tunnel = state[port]?.websocketTunnels?.[tunnelId];
|
|
70
|
+
|
|
71
|
+
if (tunnel?.tcpConnections?.[uuid]?.socket) {
|
|
72
|
+
logger.trace(`Forwarding data to TCP socket for uuid=${uuid}, tunnelId=${tunnelId}`);
|
|
73
|
+
tunnel.tcpConnections[uuid].socket.write(payload);
|
|
74
|
+
} else {
|
|
75
|
+
logger.debug(`No TCP connection found for uuid=${uuid}, tunnelId=${tunnelId}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { handleParsedMessage };
|
package/server/state.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = {};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const net = require('net');
|
|
2
|
+
const cookie = require('cookie');
|
|
3
|
+
const { v4: uuidv4 } = require('uuid');
|
|
4
|
+
const { HTTPParser, methods } = require('http-parser-js');
|
|
5
|
+
const state = require('./state');
|
|
6
|
+
const { MESSAGE_TYPE_DATA } = require('./constants');
|
|
7
|
+
const { logger } = require('../utils/logger');
|
|
8
|
+
|
|
9
|
+
function startTCPServer(port, tunnelIdHeaderName, websocketPort) {
|
|
10
|
+
const wsPortKey = String(websocketPort);
|
|
11
|
+
const tcpPortKey = String(port);
|
|
12
|
+
|
|
13
|
+
const server = net.createServer((socket) => {
|
|
14
|
+
const uuid = uuidv4();
|
|
15
|
+
const uuidBuffer = Buffer.from(uuid);
|
|
16
|
+
let currentTunnelId = null;
|
|
17
|
+
let isWebSocket = false;
|
|
18
|
+
|
|
19
|
+
logger.debug(`New TCP connection on port ${port} with uuid ${uuid}`);
|
|
20
|
+
|
|
21
|
+
function createParser() {
|
|
22
|
+
const parser = new HTTPParser(HTTPParser.REQUEST);
|
|
23
|
+
|
|
24
|
+
parser[HTTPParser.kOnHeadersComplete] = (info) => {
|
|
25
|
+
const headers = info.headers.reduce((acc, val, i, arr) => {
|
|
26
|
+
if (i % 2 === 0) acc[val.toLowerCase()] = arr[i + 1];
|
|
27
|
+
return acc;
|
|
28
|
+
}, {});
|
|
29
|
+
|
|
30
|
+
const methodName = methods[info.method] || 'UNKNOWN';
|
|
31
|
+
|
|
32
|
+
// Tunnel ID via header or cookie
|
|
33
|
+
if (headers[tunnelIdHeaderName]) {
|
|
34
|
+
currentTunnelId = headers[tunnelIdHeaderName];
|
|
35
|
+
} else if (headers['cookie']) {
|
|
36
|
+
currentTunnelId = cookie.parse(headers['cookie'])[tunnelIdHeaderName];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const tunnel = state[wsPortKey]?.websocketTunnels?.[currentTunnelId];
|
|
40
|
+
|
|
41
|
+
if (!tunnel?.ws) {
|
|
42
|
+
logger.warn(`Invalid or missing tunnel ID: ${currentTunnelId}, closing socket.`);
|
|
43
|
+
socket.destroy();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!tunnel.tcpConnections[uuid]) {
|
|
48
|
+
tunnel.tcpConnections[uuid] = { socket };
|
|
49
|
+
logger.debug(`Registered TCP connection [${uuid}] to tunnel [${currentTunnelId}]`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const rawHeaders =
|
|
53
|
+
`${methodName} ${info.url} HTTP/${info.versionMajor}.${info.versionMinor}\r\n` +
|
|
54
|
+
info.headers
|
|
55
|
+
.map((v, i) => (i % 2 === 0 ? `${v}: ${info.headers[i + 1]}` : null))
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join('\r\n') +
|
|
58
|
+
'\r\n\r\n';
|
|
59
|
+
|
|
60
|
+
isWebSocket = headers['upgrade']?.toLowerCase() === 'websocket';
|
|
61
|
+
|
|
62
|
+
logger.trace(`Sending initial headers (${rawHeaders.length} bytes) to tunnel [${currentTunnelId}]`);
|
|
63
|
+
tunnel.ws.send(Buffer.concat([uuidBuffer, Buffer.from([MESSAGE_TYPE_DATA]), Buffer.from(rawHeaders)]));
|
|
64
|
+
|
|
65
|
+
if (isWebSocket) parser.close();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
parser[HTTPParser.kOnBody] = (chunk, offset, length) => {
|
|
69
|
+
const tunnel = state[wsPortKey]?.websocketTunnels?.[currentTunnelId];
|
|
70
|
+
if (tunnel?.ws && !isWebSocket) {
|
|
71
|
+
const body = chunk.slice(offset, offset + length);
|
|
72
|
+
logger.trace(`Forwarding body (${body.length} bytes) to tunnel [${currentTunnelId}]`);
|
|
73
|
+
tunnel.ws.send(Buffer.concat([uuidBuffer, Buffer.from([MESSAGE_TYPE_DATA]), body]));
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
parser[HTTPParser.kOnMessageComplete] = () => {
|
|
78
|
+
if (!isWebSocket) {
|
|
79
|
+
logger.trace(`HTTP message complete for tunnel [${currentTunnelId}]`);
|
|
80
|
+
currentParser = createParser();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return parser;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let currentParser = createParser();
|
|
88
|
+
|
|
89
|
+
socket.on('data', (chunk) => {
|
|
90
|
+
const tunnel = state[wsPortKey]?.websocketTunnels?.[currentTunnelId];
|
|
91
|
+
if (isWebSocket) {
|
|
92
|
+
if (tunnel?.ws) {
|
|
93
|
+
logger.trace(`Forwarding WebSocket TCP data (${chunk.length} bytes) for tunnel [${currentTunnelId}]`);
|
|
94
|
+
tunnel.ws.send(Buffer.concat([uuidBuffer, Buffer.from([MESSAGE_TYPE_DATA]), chunk]));
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
try {
|
|
98
|
+
currentParser.execute(chunk);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
logger.error(`HTTP parse error on tunnel [${currentTunnelId}]:`, err);
|
|
101
|
+
socket.destroy();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
socket.on('end', () => {
|
|
107
|
+
const tunnel = state[wsPortKey]?.websocketTunnels?.[currentTunnelId];
|
|
108
|
+
if (tunnel?.ws) {
|
|
109
|
+
logger.debug(`TCP socket end for tunnel [${currentTunnelId}] (uuid: ${uuid})`);
|
|
110
|
+
tunnel.ws.send(Buffer.concat([uuidBuffer, Buffer.from([MESSAGE_TYPE_DATA]), Buffer.from('CLOSE')]));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
socket.on('close', () => {
|
|
115
|
+
const deleted = delete state[wsPortKey]?.websocketTunnels?.[currentTunnelId]?.tcpConnections?.[uuid];
|
|
116
|
+
logger.debug(`TCP socket closed [${uuid}] for tunnel [${currentTunnelId}], connection ${deleted ? 'removed' : 'not found'}`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
socket.on('error', (err) => {
|
|
120
|
+
logger.error(`Socket error on tunnel [${currentTunnelId}], uuid [${uuid}]:`, err);
|
|
121
|
+
delete state[wsPortKey]?.websocketTunnels?.[currentTunnelId]?.tcpConnections?.[uuid];
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Store reference
|
|
126
|
+
state[wsPortKey][tcpPortKey].tcpServer = server;
|
|
127
|
+
|
|
128
|
+
server.listen(port, () => {
|
|
129
|
+
logger.info(`TCP server listening on port ${port} for websocketPort ${websocketPort}`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
server.on('error', (err) => {
|
|
133
|
+
logger.error(`TCP server error on port ${port}:`, err);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { startTCPServer };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const WebSocket = require('ws');
|
|
2
|
+
const { Buffer } = require('buffer');
|
|
3
|
+
const state = require('./state');
|
|
4
|
+
const { handleParsedMessage } = require('./messageHandler');
|
|
5
|
+
const { PING_INTERVAL } = require('./constants');
|
|
6
|
+
const { logger } = require('../utils/logger');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Starts the WebSocket tunnel server.
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {number} options.port - Port to listen on.
|
|
12
|
+
* @param {string} [options.host] - Host address to bind.
|
|
13
|
+
* @param {string} [options.path] - WebSocket path.
|
|
14
|
+
* @param {string} options.tunnelIdHeaderName - Header name for identifying the tunnel.
|
|
15
|
+
*/
|
|
16
|
+
function startWebSocketServer({ port, host, path, tunnelIdHeaderName }) {
|
|
17
|
+
const portKey = String(port);
|
|
18
|
+
|
|
19
|
+
state[portKey] = state[portKey] || {};
|
|
20
|
+
state[portKey].websocketTunnels = state[portKey].websocketTunnels || {};
|
|
21
|
+
|
|
22
|
+
state[portKey].webSocketServer = new WebSocket.Server({ port, host, path });
|
|
23
|
+
|
|
24
|
+
state[portKey].webSocketServer.on('listening', () => {
|
|
25
|
+
logger.info(`WebSocket server listening on port ${port}${host ? ` (host: ${host})` : ''}${path ? `, path: ${path}` : ''}`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
state[portKey].webSocketServer.on('connection', (ws, req) => {
|
|
29
|
+
let tunnelId = null;
|
|
30
|
+
let buffer = Buffer.alloc(0);
|
|
31
|
+
|
|
32
|
+
const clientIp = req.socket.remoteAddress;
|
|
33
|
+
logger.info(`WebSocket connection established from ${clientIp}`);
|
|
34
|
+
|
|
35
|
+
// Setup heartbeat
|
|
36
|
+
ws.isAlive = true;
|
|
37
|
+
ws.on('pong', () => {
|
|
38
|
+
ws.isAlive = true;
|
|
39
|
+
logger.debug(`Pong received from client on tunnel [${tunnelId || 'unknown'}]`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const interval = setInterval(() => {
|
|
43
|
+
if (!ws.isAlive) {
|
|
44
|
+
logger.warn(`No pong received from client on tunnel [${tunnelId || 'unknown'}], terminating.`);
|
|
45
|
+
return ws.terminate();
|
|
46
|
+
}
|
|
47
|
+
ws.isAlive = false;
|
|
48
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
49
|
+
ws.ping();
|
|
50
|
+
logger.trace(`Ping sent to client on tunnel [${tunnelId || 'unknown'}]`);
|
|
51
|
+
}
|
|
52
|
+
}, PING_INTERVAL);
|
|
53
|
+
|
|
54
|
+
ws.on('message', (chunk) => {
|
|
55
|
+
logger.trace(`Received message chunk: ${chunk.length} bytes`);
|
|
56
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
57
|
+
|
|
58
|
+
while (buffer.length >= 4) {
|
|
59
|
+
const length = buffer.readUInt32BE(0);
|
|
60
|
+
if (buffer.length < 4 + length) break;
|
|
61
|
+
|
|
62
|
+
const message = buffer.slice(4, 4 + length);
|
|
63
|
+
buffer = buffer.slice(4 + length);
|
|
64
|
+
|
|
65
|
+
const messageTunnelId = message.slice(0, 36).toString();
|
|
66
|
+
const uuid = message.slice(36, 72).toString();
|
|
67
|
+
const type = message.readUInt8(72);
|
|
68
|
+
const payload = message.slice(73);
|
|
69
|
+
|
|
70
|
+
logger.trace(`Parsed message - tunnelId: ${messageTunnelId}, uuid: ${uuid}, type: ${type}, payload length: ${payload.length}`);
|
|
71
|
+
|
|
72
|
+
// Check for duplicate tunnelId on first message (when tunnelId is not yet set)
|
|
73
|
+
if (!tunnelId && messageTunnelId) {
|
|
74
|
+
const existingTunnel = state[portKey]?.websocketTunnels?.[messageTunnelId];
|
|
75
|
+
if (existingTunnel && existingTunnel.ws && existingTunnel.ws !== ws) {
|
|
76
|
+
// Check if the existing WebSocket is still open
|
|
77
|
+
if (existingTunnel.ws.readyState === WebSocket.OPEN || existingTunnel.ws.readyState === WebSocket.CONNECTING) {
|
|
78
|
+
logger.error(`Tunnel [${messageTunnelId}] already exists with an active connection. Rejecting new connection.`);
|
|
79
|
+
|
|
80
|
+
// Assign tunnelId before closing so cleanup logs the correct value
|
|
81
|
+
tunnelId = messageTunnelId;
|
|
82
|
+
|
|
83
|
+
// Close the new connection immediately
|
|
84
|
+
ws.close(1008, `Duplicate tunnelId: ${messageTunnelId}`);
|
|
85
|
+
return;
|
|
86
|
+
} else {
|
|
87
|
+
logger.info(`Existing tunnel [${messageTunnelId}] has a closed connection. Allowing new connection.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
tunnelId = messageTunnelId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
handleParsedMessage(ws, messageTunnelId, uuid, type, payload, tunnelIdHeaderName, portKey);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
function cleanup(reason = 'unknown') {
|
|
98
|
+
logger.info(`Cleaning up tunnel [${tunnelId || 'unknown'}] (reason: ${reason})`);
|
|
99
|
+
|
|
100
|
+
if (tunnelId) {
|
|
101
|
+
// Only remove from state if this WebSocket is the one actually registered
|
|
102
|
+
const registeredTunnel = state[portKey]?.websocketTunnels?.[tunnelId];
|
|
103
|
+
if (registeredTunnel && registeredTunnel.ws === ws) {
|
|
104
|
+
delete state[portKey].websocketTunnels[tunnelId];
|
|
105
|
+
logger.debug(`Removed tunnel [${tunnelId}] from state`);
|
|
106
|
+
} else {
|
|
107
|
+
logger.debug(`Tunnel [${tunnelId}] not removed - this was a duplicate/rejected connection`);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
logger.debug(`No tunnelId assigned yet, nothing to remove from state`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
clearInterval(interval);
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
ws.terminate();
|
|
117
|
+
} catch (e) {
|
|
118
|
+
logger.debug(`Error in ws.terminate:`, e);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
ws.removeAllListeners();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
ws.on('close', () => {
|
|
125
|
+
logger.info(`WebSocket connection closed for tunnel [${tunnelId || 'unknown'}]`);
|
|
126
|
+
cleanup('close');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
ws.on('error', (err) => {
|
|
130
|
+
logger.error(`WebSocket error on tunnel [${tunnelId || 'unknown'}]:`, err);
|
|
131
|
+
cleanup('error');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
state[portKey].webSocketServer.on('error', (err) => {
|
|
136
|
+
logger.error('WebSocket server error:', err);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return state;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { startWebSocketServer };
|
package/utils/index.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
require('dotenv').config();
|
|
4
|
+
const { logger, initLogger } = require('./logger');
|
|
5
|
+
const FILE_CONFIG_NAME = 'config.toml';
|
|
6
|
+
const TOML = require('@iarna/toml');
|
|
7
|
+
|
|
8
|
+
function loadConfig(customPath) {
|
|
9
|
+
const callerDir = require.main?.path || process.cwd();
|
|
10
|
+
const configPath = customPath ? path.join(customPath, FILE_CONFIG_NAME) : path.join(callerDir, FILE_CONFIG_NAME);
|
|
11
|
+
|
|
12
|
+
console.log({ configPath });
|
|
13
|
+
|
|
14
|
+
initLogger(configPath);
|
|
15
|
+
|
|
16
|
+
let fileConfig = {};
|
|
17
|
+
|
|
18
|
+
if (fs.existsSync(configPath)) {
|
|
19
|
+
try {
|
|
20
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
21
|
+
fileConfig = TOML.parse(content);
|
|
22
|
+
logger.info(`✅ Loaded configuration from config.toml at: ${configPath}`);
|
|
23
|
+
logger.debug(`Parsed config.toml content: ${JSON.stringify(fileConfig, null, 2)}`);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
logger.warn(`⚠️ Failed to parse config.toml at ${configPath}: ${err.message}`);
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
logger.info(`ℹ️ No config.toml found at: ${configPath}, falling back to environment variables.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const envConfig = {
|
|
32
|
+
tunnelId: process.env.TUNNEL_ID,
|
|
33
|
+
wsUrl: process.env.WS_URL,
|
|
34
|
+
targetUrl: process.env.TARGET_URL,
|
|
35
|
+
tunnelEntryUrl: process.env.TUNNEL_ENTRY_URL,
|
|
36
|
+
tunnelEntryPort: process.env.TUNNEL_ENTRY_PORT ? Number(process.env.TUNNEL_ENTRY_PORT) : undefined,
|
|
37
|
+
headers: process.env.HEADERS,
|
|
38
|
+
allowInsicureCerts: process.env.ALLOW_INSICURE_CERTS === 'true',
|
|
39
|
+
logLevel: process.env.LOG_LEVEL || 'info',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
logger.trace(`Loaded env config: ${JSON.stringify(envConfig, null, 2)}`);
|
|
43
|
+
|
|
44
|
+
const finalConfig = {
|
|
45
|
+
...envConfig,
|
|
46
|
+
...fileConfig, // config.toml has priority over .env
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
logger.debug(`Merged final configuration: ${JSON.stringify(finalConfig, null, 2)}`);
|
|
50
|
+
|
|
51
|
+
return finalConfig;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { loadConfig };
|
package/utils/logger.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// logger.js
|
|
2
|
+
const winston = require('winston');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const TOML = require('@iarna/toml');
|
|
6
|
+
const FILE_CONFIG_NAME = 'config.toml';
|
|
7
|
+
|
|
8
|
+
let configFilePath = null;
|
|
9
|
+
let logContext = null; // Can be set to 'CLIENT', 'SERVER', or any custom prefix
|
|
10
|
+
|
|
11
|
+
const customLevels = {
|
|
12
|
+
levels: {
|
|
13
|
+
error: 0,
|
|
14
|
+
warn: 1,
|
|
15
|
+
info: 2,
|
|
16
|
+
debug: 3,
|
|
17
|
+
trace: 4,
|
|
18
|
+
},
|
|
19
|
+
colors: {
|
|
20
|
+
error: 'red',
|
|
21
|
+
warn: 'yellow',
|
|
22
|
+
info: 'green',
|
|
23
|
+
debug: 'blue',
|
|
24
|
+
trace: 'magenta',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
winston.addColors(customLevels.colors);
|
|
29
|
+
|
|
30
|
+
const logger = winston.createLogger({
|
|
31
|
+
levels: customLevels.levels,
|
|
32
|
+
level: 'info',
|
|
33
|
+
transports: [
|
|
34
|
+
new winston.transports.Console({
|
|
35
|
+
format: winston.format.combine(
|
|
36
|
+
winston.format.timestamp(),
|
|
37
|
+
winston.format.printf((info) => {
|
|
38
|
+
const contextPrefix = logContext ? `${logContext} | ` : '';
|
|
39
|
+
// Get the raw level (before colorization)
|
|
40
|
+
const rawLevel = info[Symbol.for('level')] || info.level || 'info';
|
|
41
|
+
// Apply color to level and message separately
|
|
42
|
+
const colorizer = winston.format.colorize({ colors: customLevels.colors });
|
|
43
|
+
const coloredLevel = colorizer.colorize(rawLevel, rawLevel);
|
|
44
|
+
const coloredMessage = colorizer.colorize(rawLevel, `${contextPrefix}${info.message}`);
|
|
45
|
+
return `[${info.timestamp}] ${coloredLevel}: ${coloredMessage}`;
|
|
46
|
+
})
|
|
47
|
+
),
|
|
48
|
+
}),
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function setLogLevel(level) {
|
|
53
|
+
level = level || 'info';
|
|
54
|
+
if (!(level in customLevels.levels)) {
|
|
55
|
+
logger.warn(`Invalid log level: ${level}`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
logger.level = level;
|
|
59
|
+
logger.info(`Log level changed to: ${level}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getLogLevel() {
|
|
63
|
+
return logger.level;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function setLogContext(context) {
|
|
67
|
+
logContext = context;
|
|
68
|
+
if (context) {
|
|
69
|
+
logger.info(`Log context set to: ${context}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getLogContext() {
|
|
74
|
+
return logContext;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function loadConfigFromFile() {
|
|
78
|
+
try {
|
|
79
|
+
const content = fs.readFileSync(configFilePath, 'utf8');
|
|
80
|
+
const parsed = TOML.parse(content);
|
|
81
|
+
// const parsed = JSON.parse(content);
|
|
82
|
+
if (parsed.logLevel) {
|
|
83
|
+
setLogLevel(parsed.logLevel);
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
logger.warn(`Could not read or parse ${configFilePath}: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function watchLogConfig() {
|
|
91
|
+
fs.watchFile(configFilePath, { interval: 1000 }, (curr, prev) => {
|
|
92
|
+
if (curr.mtime !== prev.mtime) {
|
|
93
|
+
logger.debug('Detected change in log configuration file.');
|
|
94
|
+
loadConfigFromFile();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function initLogger(customPath = null) {
|
|
100
|
+
if (customPath) {
|
|
101
|
+
const resolvedPath = path.resolve(customPath);
|
|
102
|
+
try {
|
|
103
|
+
const stats = fs.statSync(resolvedPath);
|
|
104
|
+
if (stats.isFile()) {
|
|
105
|
+
configFilePath = resolvedPath;
|
|
106
|
+
} else if (stats.isDirectory()) {
|
|
107
|
+
configFilePath = path.join(resolvedPath, FILE_CONFIG_NAME);
|
|
108
|
+
} else {
|
|
109
|
+
logger.warn(`Invalid config path provided: ${resolvedPath}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
logger.warn(`Cannot access config path: ${resolvedPath} (${err.message})`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
const baseDir = process.cwd();
|
|
118
|
+
configFilePath = path.join(baseDir, FILE_CONFIG_NAME);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
logger.debug(`Logger will read config from: ${configFilePath}`);
|
|
122
|
+
|
|
123
|
+
loadConfigFromFile();
|
|
124
|
+
watchLogConfig();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// initLogger();
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
logger,
|
|
131
|
+
initLogger,
|
|
132
|
+
setLogLevel,
|
|
133
|
+
getLogLevel,
|
|
134
|
+
setLogContext,
|
|
135
|
+
getLogContext,
|
|
136
|
+
};
|