@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.
Files changed (2) hide show
  1. package/dist/main.js +219 -21
  2. 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.sendCommand({
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 is required"
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.pollMessages({
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 is required"
439
+ message: "tunnel_id, auth_key, and device_id are required"
436
440
  });
437
441
  }
438
- const result = httpClientOperations.getState({
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 is required"
464
+ message: "tunnel_id, auth_key, and device_id are required"
459
465
  });
460
466
  }
461
- const disconnected = httpClientOperations.disconnect(device_id);
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
- client.close(1001, "Server shutting down");
993
+ try {
994
+ client.close(1001, "Server shutting down");
995
+ } catch {
996
+ }
981
997
  });
982
- return new Promise((resolve, reject) => {
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.1",
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": ">=22.0.0"
56
+ "node": ">=24.0.0"
57
57
  },
58
58
  "scripts": {
59
59
  "dev": "tsx watch src/main.ts",