@tiflis-io/tiflis-code-tunnel 0.3.1 → 0.3.3
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/dist/main.js +219 -21
- package/package.json +2 -2
package/dist/main.js
CHANGED
|
@@ -372,14 +372,16 @@ function registerWatchApiRoute(app, deps) {
|
|
|
372
372
|
});
|
|
373
373
|
app.post("/api/v1/watch/command", async (request, reply) => {
|
|
374
374
|
try {
|
|
375
|
-
const { device_id, message } = request.body;
|
|
376
|
-
if (!device_id) {
|
|
375
|
+
const { tunnel_id, auth_key, device_id, message } = request.body;
|
|
376
|
+
if (!tunnel_id || !auth_key || !device_id) {
|
|
377
377
|
return await reply.status(400).send({
|
|
378
378
|
error: "missing_parameters",
|
|
379
|
-
message: "device_id and message are required"
|
|
379
|
+
message: "tunnel_id, auth_key, device_id, and message are required"
|
|
380
380
|
});
|
|
381
381
|
}
|
|
382
|
-
const sent = httpClientOperations.
|
|
382
|
+
const sent = httpClientOperations.sendCommandWithAuth({
|
|
383
|
+
tunnelId: tunnel_id,
|
|
384
|
+
authKey: auth_key,
|
|
383
385
|
deviceId: device_id,
|
|
384
386
|
message
|
|
385
387
|
});
|
|
@@ -398,16 +400,18 @@ function registerWatchApiRoute(app, deps) {
|
|
|
398
400
|
});
|
|
399
401
|
app.get("/api/v1/watch/messages", async (request, reply) => {
|
|
400
402
|
try {
|
|
401
|
-
const { device_id, since, ack } = request.query;
|
|
402
|
-
if (!device_id) {
|
|
403
|
+
const { tunnel_id, auth_key, device_id, since, ack } = request.query;
|
|
404
|
+
if (!tunnel_id || !auth_key || !device_id) {
|
|
403
405
|
return await reply.status(400).send({
|
|
404
406
|
error: "missing_parameters",
|
|
405
|
-
message: "device_id
|
|
407
|
+
message: "tunnel_id, auth_key, and device_id are required"
|
|
406
408
|
});
|
|
407
409
|
}
|
|
408
410
|
const sinceSequence = since ? parseInt(since, 10) : 0;
|
|
409
411
|
const ackSequence = ack ? parseInt(ack, 10) : void 0;
|
|
410
|
-
const result = httpClientOperations.
|
|
412
|
+
const result = httpClientOperations.pollMessagesWithAuth({
|
|
413
|
+
tunnelId: tunnel_id,
|
|
414
|
+
authKey: auth_key,
|
|
411
415
|
deviceId: device_id,
|
|
412
416
|
sinceSequence,
|
|
413
417
|
acknowledgeSequence: ackSequence
|
|
@@ -428,14 +432,16 @@ function registerWatchApiRoute(app, deps) {
|
|
|
428
432
|
});
|
|
429
433
|
app.get("/api/v1/watch/state", async (request, reply) => {
|
|
430
434
|
try {
|
|
431
|
-
const { device_id } = request.query;
|
|
432
|
-
if (!device_id) {
|
|
435
|
+
const { tunnel_id, auth_key, device_id } = request.query;
|
|
436
|
+
if (!tunnel_id || !auth_key || !device_id) {
|
|
433
437
|
return await reply.status(400).send({
|
|
434
438
|
error: "missing_parameters",
|
|
435
|
-
message: "device_id
|
|
439
|
+
message: "tunnel_id, auth_key, and device_id are required"
|
|
436
440
|
});
|
|
437
441
|
}
|
|
438
|
-
const result = httpClientOperations.
|
|
442
|
+
const result = httpClientOperations.getStateWithAuth({
|
|
443
|
+
tunnelId: tunnel_id,
|
|
444
|
+
authKey: auth_key,
|
|
439
445
|
deviceId: device_id
|
|
440
446
|
});
|
|
441
447
|
return await reply.status(200).send({
|
|
@@ -451,14 +457,18 @@ function registerWatchApiRoute(app, deps) {
|
|
|
451
457
|
});
|
|
452
458
|
app.post("/api/v1/watch/disconnect", async (request, reply) => {
|
|
453
459
|
try {
|
|
454
|
-
const { device_id } = request.body;
|
|
455
|
-
if (!device_id) {
|
|
460
|
+
const { tunnel_id, auth_key, device_id } = request.body;
|
|
461
|
+
if (!tunnel_id || !auth_key || !device_id) {
|
|
456
462
|
return await reply.status(400).send({
|
|
457
463
|
error: "missing_parameters",
|
|
458
|
-
message: "device_id
|
|
464
|
+
message: "tunnel_id, auth_key, and device_id are required"
|
|
459
465
|
});
|
|
460
466
|
}
|
|
461
|
-
const disconnected = httpClientOperations.
|
|
467
|
+
const disconnected = httpClientOperations.disconnectWithAuth({
|
|
468
|
+
tunnelId: tunnel_id,
|
|
469
|
+
authKey: auth_key,
|
|
470
|
+
deviceId: device_id
|
|
471
|
+
});
|
|
462
472
|
log.info({ deviceId: device_id }, "Watch disconnected via HTTP");
|
|
463
473
|
return await reply.status(200).send({
|
|
464
474
|
success: disconnected
|
|
@@ -967,29 +977,51 @@ var WebSocketServerWrapper = class {
|
|
|
967
977
|
return this.wss?.clients.size ?? 0;
|
|
968
978
|
}
|
|
969
979
|
/**
|
|
970
|
-
* Closes the WebSocket server gracefully.
|
|
980
|
+
* Closes the WebSocket server gracefully with timeout.
|
|
981
|
+
* @param timeoutMs - Maximum time to wait for graceful close (default: 5000ms)
|
|
971
982
|
*/
|
|
972
|
-
async close() {
|
|
983
|
+
async close(timeoutMs = 5e3) {
|
|
973
984
|
if (this.heartbeatInterval) {
|
|
974
985
|
clearInterval(this.heartbeatInterval);
|
|
975
986
|
this.heartbeatInterval = null;
|
|
976
987
|
}
|
|
977
988
|
const wss = this.wss;
|
|
978
989
|
if (!wss) return;
|
|
990
|
+
const clientCount = wss.clients.size;
|
|
991
|
+
this.logger.info({ clientCount }, "Closing WebSocket server");
|
|
979
992
|
wss.clients.forEach((client) => {
|
|
980
|
-
|
|
993
|
+
try {
|
|
994
|
+
client.close(1001, "Server shutting down");
|
|
995
|
+
} catch {
|
|
996
|
+
}
|
|
981
997
|
});
|
|
982
|
-
|
|
998
|
+
const closePromise = new Promise((resolve, reject) => {
|
|
983
999
|
wss.close((err) => {
|
|
984
1000
|
if (err) {
|
|
985
1001
|
this.logger.error({ error: err }, "Error closing WebSocket server");
|
|
986
1002
|
reject(err);
|
|
987
1003
|
} else {
|
|
988
|
-
this.logger.info("WebSocket server closed");
|
|
1004
|
+
this.logger.info("WebSocket server closed gracefully");
|
|
989
1005
|
resolve();
|
|
990
1006
|
}
|
|
991
1007
|
});
|
|
992
1008
|
});
|
|
1009
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
1010
|
+
setTimeout(() => {
|
|
1011
|
+
this.logger.warn(
|
|
1012
|
+
{ timeoutMs, remainingClients: wss.clients.size },
|
|
1013
|
+
"WebSocket graceful close timed out, forcing termination"
|
|
1014
|
+
);
|
|
1015
|
+
wss.clients.forEach((client) => {
|
|
1016
|
+
try {
|
|
1017
|
+
client.terminate();
|
|
1018
|
+
} catch {
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
resolve();
|
|
1022
|
+
}, timeoutMs);
|
|
1023
|
+
});
|
|
1024
|
+
await Promise.race([closePromise, timeoutPromise]);
|
|
993
1025
|
}
|
|
994
1026
|
};
|
|
995
1027
|
|
|
@@ -1817,6 +1849,7 @@ var HttpClientOperationsUseCase = class {
|
|
|
1817
1849
|
throw new InvalidAuthKeyError();
|
|
1818
1850
|
}
|
|
1819
1851
|
let client = this.httpClientRegistry.get(deviceId);
|
|
1852
|
+
const isNewClient = !client;
|
|
1820
1853
|
if (client) {
|
|
1821
1854
|
client.recordPoll();
|
|
1822
1855
|
this.logger.info({ deviceId, tunnelId: tunnelIdStr }, "HTTP client reconnected");
|
|
@@ -1828,6 +1861,21 @@ var HttpClientOperationsUseCase = class {
|
|
|
1828
1861
|
this.httpClientRegistry.register(client);
|
|
1829
1862
|
this.logger.info({ deviceId, tunnelId: tunnelIdStr }, "HTTP client registered");
|
|
1830
1863
|
}
|
|
1864
|
+
if (workstation.isOnline) {
|
|
1865
|
+
const authMessage = {
|
|
1866
|
+
type: "auth",
|
|
1867
|
+
payload: {
|
|
1868
|
+
auth_key: authKeyStr,
|
|
1869
|
+
device_id: deviceId
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1872
|
+
const sent = workstation.send(JSON.stringify(authMessage));
|
|
1873
|
+
if (sent) {
|
|
1874
|
+
this.logger.debug({ deviceId, isNewClient }, "Forwarded auth to workstation for HTTP client");
|
|
1875
|
+
} else {
|
|
1876
|
+
this.logger.warn({ deviceId }, "Failed to forward auth to workstation");
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1831
1879
|
return {
|
|
1832
1880
|
success: true,
|
|
1833
1881
|
tunnelId: tunnelIdStr,
|
|
@@ -1866,6 +1914,57 @@ var HttpClientOperationsUseCase = class {
|
|
|
1866
1914
|
}
|
|
1867
1915
|
return sent;
|
|
1868
1916
|
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Sends a command from HTTP client to workstation with auth validation.
|
|
1919
|
+
* This method validates auth on every request and forwards auth to workstation
|
|
1920
|
+
* to ensure the device_id is registered.
|
|
1921
|
+
*/
|
|
1922
|
+
sendCommandWithAuth(input) {
|
|
1923
|
+
const { tunnelId: tunnelIdStr, authKey: authKeyStr, deviceId, message } = input;
|
|
1924
|
+
this.logger.info({ deviceId, tunnelId: tunnelIdStr, messageType: message.type }, "HTTP client sending command with auth");
|
|
1925
|
+
const tunnelId = TunnelId.create(tunnelIdStr);
|
|
1926
|
+
const authKey = AuthKey.create(authKeyStr);
|
|
1927
|
+
const workstation = this.workstationRegistry.get(tunnelId);
|
|
1928
|
+
if (!workstation) {
|
|
1929
|
+
this.logger.warn({ tunnelId: tunnelIdStr, deviceId }, "Workstation not found for command");
|
|
1930
|
+
throw new TunnelNotFoundError(tunnelIdStr);
|
|
1931
|
+
}
|
|
1932
|
+
if (!workstation.validateAuthKey(authKey)) {
|
|
1933
|
+
this.logger.warn({ tunnelId: tunnelIdStr, deviceId }, "Invalid auth key for command");
|
|
1934
|
+
throw new InvalidAuthKeyError();
|
|
1935
|
+
}
|
|
1936
|
+
if (!workstation.isOnline) {
|
|
1937
|
+
this.logger.warn({ deviceId, tunnelId: tunnelIdStr }, "Workstation offline");
|
|
1938
|
+
throw new WorkstationOfflineError(tunnelIdStr);
|
|
1939
|
+
}
|
|
1940
|
+
let client = this.httpClientRegistry.get(deviceId);
|
|
1941
|
+
if (!client) {
|
|
1942
|
+
client = new HttpClient({
|
|
1943
|
+
deviceId,
|
|
1944
|
+
tunnelId
|
|
1945
|
+
});
|
|
1946
|
+
this.httpClientRegistry.register(client);
|
|
1947
|
+
this.logger.info({ deviceId, tunnelId: tunnelIdStr }, "HTTP client registered on command");
|
|
1948
|
+
}
|
|
1949
|
+
client.recordPoll();
|
|
1950
|
+
const authMessage = {
|
|
1951
|
+
type: "auth",
|
|
1952
|
+
payload: {
|
|
1953
|
+
auth_key: authKeyStr,
|
|
1954
|
+
device_id: deviceId
|
|
1955
|
+
}
|
|
1956
|
+
};
|
|
1957
|
+
workstation.send(JSON.stringify(authMessage));
|
|
1958
|
+
const enrichedMessage = {
|
|
1959
|
+
...message,
|
|
1960
|
+
device_id: deviceId
|
|
1961
|
+
};
|
|
1962
|
+
const sent = workstation.send(JSON.stringify(enrichedMessage));
|
|
1963
|
+
if (!sent) {
|
|
1964
|
+
this.logger.warn({ deviceId }, "Failed to send command to workstation");
|
|
1965
|
+
}
|
|
1966
|
+
return sent;
|
|
1967
|
+
}
|
|
1869
1968
|
/**
|
|
1870
1969
|
* Polls for messages for an HTTP client.
|
|
1871
1970
|
*/
|
|
@@ -1934,6 +2033,98 @@ var HttpClientOperationsUseCase = class {
|
|
|
1934
2033
|
this.logger.info({ deviceId }, "HTTP client disconnected");
|
|
1935
2034
|
return true;
|
|
1936
2035
|
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Polls for messages with auth validation (stateless).
|
|
2038
|
+
* Validates auth on every request.
|
|
2039
|
+
*/
|
|
2040
|
+
pollMessagesWithAuth(input) {
|
|
2041
|
+
const { tunnelId: tunnelIdStr, authKey: authKeyStr, deviceId, sinceSequence, acknowledgeSequence } = input;
|
|
2042
|
+
const tunnelId = TunnelId.create(tunnelIdStr);
|
|
2043
|
+
const authKey = AuthKey.create(authKeyStr);
|
|
2044
|
+
const workstation = this.workstationRegistry.get(tunnelId);
|
|
2045
|
+
if (!workstation) {
|
|
2046
|
+
throw new TunnelNotFoundError(tunnelIdStr);
|
|
2047
|
+
}
|
|
2048
|
+
if (!workstation.validateAuthKey(authKey)) {
|
|
2049
|
+
throw new InvalidAuthKeyError();
|
|
2050
|
+
}
|
|
2051
|
+
let client = this.httpClientRegistry.get(deviceId);
|
|
2052
|
+
if (!client) {
|
|
2053
|
+
client = new HttpClient({
|
|
2054
|
+
deviceId,
|
|
2055
|
+
tunnelId
|
|
2056
|
+
});
|
|
2057
|
+
this.httpClientRegistry.register(client);
|
|
2058
|
+
}
|
|
2059
|
+
client.recordPoll();
|
|
2060
|
+
if (acknowledgeSequence !== void 0 && acknowledgeSequence > 0) {
|
|
2061
|
+
client.acknowledgeMessages(acknowledgeSequence);
|
|
2062
|
+
}
|
|
2063
|
+
const messages = client.getMessagesSince(sinceSequence);
|
|
2064
|
+
this.logger.debug(
|
|
2065
|
+
{ deviceId, sinceSequence, messageCount: messages.length, currentSequence: client.currentSequence },
|
|
2066
|
+
"HTTP client poll with auth"
|
|
2067
|
+
);
|
|
2068
|
+
return {
|
|
2069
|
+
messages,
|
|
2070
|
+
currentSequence: client.currentSequence,
|
|
2071
|
+
workstationOnline: workstation.isOnline
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Gets current state with auth validation (stateless).
|
|
2076
|
+
*/
|
|
2077
|
+
getStateWithAuth(input) {
|
|
2078
|
+
const { tunnelId: tunnelIdStr, authKey: authKeyStr, deviceId } = input;
|
|
2079
|
+
const tunnelId = TunnelId.create(tunnelIdStr);
|
|
2080
|
+
const authKey = AuthKey.create(authKeyStr);
|
|
2081
|
+
const workstation = this.workstationRegistry.get(tunnelId);
|
|
2082
|
+
if (!workstation) {
|
|
2083
|
+
throw new TunnelNotFoundError(tunnelIdStr);
|
|
2084
|
+
}
|
|
2085
|
+
if (!workstation.validateAuthKey(authKey)) {
|
|
2086
|
+
throw new InvalidAuthKeyError();
|
|
2087
|
+
}
|
|
2088
|
+
let client = this.httpClientRegistry.get(deviceId);
|
|
2089
|
+
if (!client) {
|
|
2090
|
+
client = new HttpClient({
|
|
2091
|
+
deviceId,
|
|
2092
|
+
tunnelId
|
|
2093
|
+
});
|
|
2094
|
+
this.httpClientRegistry.register(client);
|
|
2095
|
+
}
|
|
2096
|
+
client.recordPoll();
|
|
2097
|
+
return {
|
|
2098
|
+
connected: true,
|
|
2099
|
+
workstationOnline: workstation.isOnline,
|
|
2100
|
+
workstationName: workstation.name,
|
|
2101
|
+
queueSize: client.queueSize,
|
|
2102
|
+
currentSequence: client.currentSequence
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
/**
|
|
2106
|
+
* Disconnects an HTTP client with auth validation (stateless).
|
|
2107
|
+
*/
|
|
2108
|
+
disconnectWithAuth(input) {
|
|
2109
|
+
const { tunnelId: tunnelIdStr, authKey: authKeyStr, deviceId } = input;
|
|
2110
|
+
const tunnelId = TunnelId.create(tunnelIdStr);
|
|
2111
|
+
const authKey = AuthKey.create(authKeyStr);
|
|
2112
|
+
const workstation = this.workstationRegistry.get(tunnelId);
|
|
2113
|
+
if (!workstation) {
|
|
2114
|
+
throw new TunnelNotFoundError(tunnelIdStr);
|
|
2115
|
+
}
|
|
2116
|
+
if (!workstation.validateAuthKey(authKey)) {
|
|
2117
|
+
throw new InvalidAuthKeyError();
|
|
2118
|
+
}
|
|
2119
|
+
const client = this.httpClientRegistry.get(deviceId);
|
|
2120
|
+
if (!client) {
|
|
2121
|
+
return false;
|
|
2122
|
+
}
|
|
2123
|
+
client.markInactive();
|
|
2124
|
+
this.httpClientRegistry.unregister(deviceId);
|
|
2125
|
+
this.logger.info({ deviceId }, "HTTP client disconnected with auth");
|
|
2126
|
+
return true;
|
|
2127
|
+
}
|
|
1937
2128
|
/**
|
|
1938
2129
|
* Queues a message for all HTTP clients connected to a tunnel.
|
|
1939
2130
|
* Called when workstation sends a message.
|
|
@@ -2111,14 +2302,21 @@ async function bootstrap() {
|
|
|
2111
2302
|
logger.fatal({ error }, "Failed to start server");
|
|
2112
2303
|
process.exit(1);
|
|
2113
2304
|
}
|
|
2305
|
+
const SHUTDOWN_TIMEOUT_MS = 1e4;
|
|
2114
2306
|
const shutdown = async (signal) => {
|
|
2115
2307
|
logger.info({ signal }, "Shutdown signal received");
|
|
2308
|
+
const forceExitTimer = setTimeout(() => {
|
|
2309
|
+
logger.error("Shutdown timed out, forcing exit");
|
|
2310
|
+
process.exit(1);
|
|
2311
|
+
}, SHUTDOWN_TIMEOUT_MS);
|
|
2116
2312
|
try {
|
|
2117
2313
|
await wsServer.close();
|
|
2118
2314
|
await app.close();
|
|
2315
|
+
clearTimeout(forceExitTimer);
|
|
2119
2316
|
logger.info("Shutdown complete");
|
|
2120
2317
|
process.exit(0);
|
|
2121
2318
|
} catch (error) {
|
|
2319
|
+
clearTimeout(forceExitTimer);
|
|
2122
2320
|
logger.error({ error }, "Error during shutdown");
|
|
2123
2321
|
process.exit(1);
|
|
2124
2322
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiflis-io/tiflis-code-tunnel",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Tunnel server for tiflis-code - reverse proxy for workstation connections",
|
|
5
5
|
"author": "Roman Barinov <rbarinov@gmail.com>",
|
|
6
6
|
"license": "FSL-1.1-NC",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"vitest": "^2.1.8"
|
|
54
54
|
},
|
|
55
55
|
"engines": {
|
|
56
|
-
"node": ">=
|
|
56
|
+
"node": ">=24.0.0"
|
|
57
57
|
},
|
|
58
58
|
"scripts": {
|
|
59
59
|
"dev": "tsx watch src/main.ts",
|