socketduct 0.1.34 → 0.1.35

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.
@@ -0,0 +1 @@
1
+ The reverse hub's periodic status now reports rejected opens broken down by reset code, and clients log each relay reset as a `virtual_stream.reset` diagnostic event, so gateway outages can be told apart from authorization or routing rejections without new instrumentation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "description": "Resilient authenticated tunnel/proxy for keeping socket streams alive across flaky links.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -959,6 +959,7 @@ export class MultiplexedSocketductConnection extends EventEmitter {
959
959
  const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
960
960
  stream.remoteClosed = true
961
961
  stream.state = "reset"
962
+ logDiagnostic(this.#options.logger, "virtual_stream.reset", {streamId: stream.id, errorCode: code})
962
963
  const lost = code.startsWith("RESUME_") || code === "SESSION_LOST" || code.includes("UNCERTAIN")
963
964
  stream.socket._receiveError(lost
964
965
  ? new SocketductSessionLostError("Virtual stream could not be resumed", code)
@@ -27,6 +27,7 @@ const COMPRESSION = new Set(["auto", "off", "zstd", "none"])
27
27
  const STATES = new Set(["configured", "connecting", "registering", "ready", "reconnecting", "disconnected", "closed", "starting", "degraded", "failed"])
28
28
  const SAFE_ERROR_CODES = new Set([
29
29
  "AUTH_FAILED",
30
+ "AMBIGUOUS_ROUTE",
30
31
  "AUTH_NEGOTIATION_FAILED",
31
32
  "AUTH_OWNERSHIP_MISMATCH",
32
33
  "AUTH_REQUIRED",
@@ -51,6 +52,7 @@ const SAFE_ERROR_CODES = new Set([
51
52
  "HANDSHAKE_TIMEOUT",
52
53
  "HUB_SHUTDOWN",
53
54
  "INPUT_LIMIT_EXCEEDED",
55
+ "INVALID_ROUTE_RESOLUTION",
54
56
  "INVALID_STREAM_OPEN",
55
57
  "PROTOCOL_NEGOTIATION_FAILED",
56
58
  "PROTOCOL_VIOLATION",
@@ -77,6 +79,7 @@ const SAFE_ERROR_CODES = new Set([
77
79
  "TARGET_OPEN_TIMEOUT",
78
80
  "TARGET_READ_FAILED",
79
81
  "TARGET_UNAUTHORIZED",
82
+ "UNKNOWN_GATEWAY_ID",
80
83
  "TLS_ERROR",
81
84
  "TRANSPORT_CLOSED",
82
85
  "UNKNOWN"
@@ -156,7 +159,7 @@ const NUMBER_FIELDS = new Set([
156
159
  "virtualStreams",
157
160
  "wireDataBytes"
158
161
  ])
159
- const RUNTIME_STATUS_FIELDS = new Set([...NUMBER_FIELDS, "compression", "physicalState"])
162
+ const RUNTIME_STATUS_FIELDS = new Set([...NUMBER_FIELDS, "compression", "physicalState", "rejectedOpensByCode"])
160
163
  const EVENT_FIELDS = new Map([
161
164
  ["process.started", new Set(["runtimeMode", "transportSecurity", "compression", "compressionLevel", "frameMaxBytes", "flushIntervalMs", "criticalFlushIntervalMs", "maxSegmentBytes", "retentionDays", "retentionBytes", "maxQueueBytes", "criticalReserveBytes"])],
162
165
  ["process.stopped", new Set(["runtimeMode"])],
@@ -174,6 +177,7 @@ const EVENT_FIELDS = new Map([
174
177
  ["transport_set.terminal_failure", new Set(["configuredMembers", "closedMembers", "errorCode"])],
175
178
  ["virtual_stream.opening", new Set(["streamId", "generation", "gatewayId", "recovered"])],
176
179
  ["virtual_stream.opened", new Set(["streamId", "generation", "gatewayId", "recovered"])],
180
+ ["virtual_stream.reset", new Set(["streamId", "errorCode"])],
177
181
  ["virtual_stream.closed", new Set(["streamId", "generation", "gatewayId"])],
178
182
  ["virtual_stream.error", new Set(["streamId", "generation", "gatewayId", "routeClass", "errorCode", "disconnectIncidentId", "disconnectReason", "incidentOrigin", "physicalRole", "clientConnectionGeneration", "gatewayConnectionGeneration", "fanOutCount", "resumeSuccessCount", "terminalStreamCount"])],
179
183
  ["reverse_gateway.target_opened", new Set(["streamId", "generation", "gatewayId"])],
@@ -249,7 +253,7 @@ export function formatDiagnosticLogLine(options) {
249
253
  const base = {timestamp: timestamp.toISOString(), event: options.event, processId, role: options.role, ...envelope}
250
254
  let line = formatLogLine(base, safeFields)
251
255
  if (Buffer.byteLength(line) > MAX_JSONL_BYTES && safeFields.errorCode !== undefined) {
252
- /** @type {Record<string, string | number | boolean>} */
256
+ /** @type {Record<string, string | number | boolean | Record<string, number>>} */
253
257
  const boundedFields = {...safeFields}
254
258
  delete boundedFields.errorStack
255
259
  if (typeof boundedFields.errorMessage === "string") {
@@ -595,12 +599,12 @@ export function errorDiagnostics(error, fallback = "UNKNOWN") {
595
599
  /**
596
600
  * @param {string} event - Event name.
597
601
  * @param {Record<string, unknown>} fields - Candidate fields.
598
- * @returns {Record<string, string | number | boolean> | undefined} Allowlisted fields, or undefined for an unknown event.
602
+ * @returns {Record<string, string | number | boolean | Record<string, number>> | undefined} Allowlisted fields, or undefined for an unknown event.
599
603
  */
600
604
  function sanitizeFields(event, fields) {
601
605
  const allowed = EVENT_FIELDS.get(event)
602
606
  if (allowed === undefined || typeof fields !== "object" || fields === null || Array.isArray(fields)) return undefined
603
- /** @type {Record<string, string | number | boolean>} */
607
+ /** @type {Record<string, string | number | boolean | Record<string, number>>} */
604
608
  const safe = {}
605
609
  for (const field of allowed) {
606
610
  const value = fields[field]
@@ -656,6 +660,22 @@ function sanitizeFields(event, fields) {
656
660
  const errorCode = normalizeErrorCode(value)
657
661
  safe.errorCode = errorCode
658
662
  safe.errorClass = classifyError(errorCode)
663
+ } else if (field === "rejectedOpensByCode" && typeof value === "object" && value !== null && !Array.isArray(value)) {
664
+ const entries = Object.entries(value)
665
+ if (entries.length <= 16) {
666
+ /** @type {Record<string, number>} */
667
+ const bounded = {}
668
+ let valid = true
669
+
670
+ for (const [code, count] of entries) {
671
+ if (!SAFE_ERROR_CODES.has(code) || typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) {
672
+ valid = false
673
+ break
674
+ }
675
+ bounded[code] = count
676
+ }
677
+ if (valid) safe.rejectedOpensByCode = bounded
678
+ }
659
679
  }
660
680
  }
661
681
  const errorDetails = Object.getOwnPropertyDescriptor(fields, ERROR_DETAILS)?.value
@@ -671,7 +691,7 @@ function sanitizeFields(event, fields) {
671
691
 
672
692
  /**
673
693
  * @param {{event: string, processId: number, role: string, timestamp: string}} base - Required JSONL fields.
674
- * @param {Record<string, string | number | boolean>} fields - Sanitized event fields.
694
+ * @param {Record<string, string | number | boolean | Record<string, number>>} fields - Sanitized event fields.
675
695
  * @returns {string} One JSONL record.
676
696
  */
677
697
  function formatLogLine(base, fields) {
@@ -175,6 +175,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
175
175
  let gatewayRegistrations = 0
176
176
  let rejectedRegistrations = 0
177
177
  let rejectedOpens = 0
178
+ /** @type {Map<string, number>} */
179
+ const rejectedOpensByCode = new Map()
178
180
  let rawDataBytes = 0
179
181
  let wireDataBytes = 0
180
182
  let closing = false
@@ -707,6 +709,12 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
707
709
  }
708
710
  }
709
711
 
712
+ /** @param {string} code - V3 reset code sent to the rejecting client. */
713
+ function countRejectedOpen(code) {
714
+ rejectedOpens += 1
715
+ rejectedOpensByCode.set(code, (rejectedOpensByCode.get(code) || 0) + 1)
716
+ }
717
+
710
718
  /** @param {Record<string, unknown>} value - Resume manifest entry. */
711
719
  function parseResumeItem(value) {
712
720
  const item = /** @type {{streamId?: unknown, identifier?: unknown, clientToGatewaySequence?: unknown, clientToGatewayAck?: unknown, gatewayToClientSequence?: unknown, gatewayToClientAck?: unknown, localEnded?: unknown, remoteEnded?: unknown}} */ (value)
@@ -734,17 +742,17 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
734
742
  }
735
743
  const candidates = options.resolveOpenGateways?.({principal: client.principal, routeClass: client.routeClass, target})
736
744
  if (!Array.isArray(candidates) || candidates.some((candidate) => typeof candidate !== "string" || !GATEWAY_ID.test(candidate))) {
737
- rejectedOpens += 1
745
+ countRejectedOpen("INVALID_ROUTE_RESOLUTION")
738
746
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_ROUTE_RESOLUTION"})
739
747
  return
740
748
  }
741
749
  if (candidates.length === 0) {
742
- rejectedOpens += 1
750
+ countRejectedOpen("TARGET_UNAUTHORIZED")
743
751
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
744
752
  return
745
753
  }
746
754
  if (candidates.length !== 1 || new Set(candidates).size !== 1) {
747
- rejectedOpens += 1
755
+ countRejectedOpen("AMBIGUOUS_ROUTE")
748
756
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "AMBIGUOUS_ROUTE"})
749
757
  return
750
758
  }
@@ -757,15 +765,15 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
757
765
  gatewayId = request.gatewayId
758
766
  }
759
767
  if (options.authorizeOpen({principal: client.principal, gatewayId, target}) !== true) {
760
- rejectedOpens += 1
768
+ countRejectedOpen("TARGET_UNAUTHORIZED")
761
769
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
762
770
  return
763
771
  }
764
772
  const gatewayOwner = gateways.get(gatewayId)
765
773
  const gateway = gatewayOwner?.physical
766
774
  if (gatewayOwner === undefined || gateway === undefined || gateway.socket.destroyed || gateway.closing || gateway.resumeRequired || gateway.role !== "gateway" || gateway.gatewayId !== gatewayId || gatewayOwner.gatewayId !== gatewayId || gatewayOwner.userId !== gateway.principal?.userId) {
767
- rejectedOpens += 1
768
775
  const known = gatewayOwner !== undefined || options.knownGatewayIds?.includes(gatewayId) === true
776
+ countRejectedOpen(known ? "GATEWAY_UNAVAILABLE" : "UNKNOWN_GATEWAY_ID")
769
777
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: known ? "GATEWAY_UNAVAILABLE" : "UNKNOWN_GATEWAY_ID"})
770
778
  return
771
779
  }
@@ -1222,6 +1230,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
1222
1230
  gatewayRegistrations,
1223
1231
  rejectedRegistrations,
1224
1232
  rejectedOpens,
1233
+ rejectedOpensByCode: Object.fromEntries(rejectedOpensByCode),
1225
1234
  virtualStreams: bridges,
1226
1235
  rawDataBytes,
1227
1236
  wireDataBytes,
@@ -80,8 +80,11 @@ describe("multiplexed failures", () => {
80
80
  it("authentication and exact-target authorization fail closed", async () => withTestCleanup(async (after) => {
81
81
  const relay = await startMultiplexedTcpRelay({tokens: tokens(), authorizeTarget: () => false})
82
82
  after(() => relay.close())
83
+ /** @type {Array<{event: string} & Record<string, unknown>>} */
84
+ const lifecycle = []
83
85
  const unauthorizedConnection = new MultiplexedSocketductConnection({
84
- relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}
86
+ relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"},
87
+ logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => lifecycle.push({event, ...fields})}
85
88
  })
86
89
  after(() => unauthorizedConnection.close())
87
90
  const denied = unauthorizedConnection.createConnection({host: "127.0.0.1", port: 9})
@@ -89,6 +92,7 @@ describe("multiplexed failures", () => {
89
92
  expect(deniedError instanceof SocketductStreamError).toBeTruthy()
90
93
  expect(deniedError.code).toBe("TARGET_UNAUTHORIZED")
91
94
  expect(relay.status().activeTargets).toBe(0)
95
+ expect({value: lifecycle.find((entry) => entry.event === "virtual_stream.reset")?.errorCode, context: "the client logs the relay reset code"}).toMatchObject({value: "TARGET_UNAUTHORIZED"})
92
96
 
93
97
  const invalidConnection = new MultiplexedSocketductConnection({
94
98
  relay: {host: "127.0.0.1", port: relay.port, token: "wrong-token"},
@@ -533,6 +533,7 @@ describe("reverse gateway route-class", () => {
533
533
  const reset = await nextError(unavailableStream)
534
534
  expect(reset.code).toBe("GATEWAY_UNAVAILABLE")
535
535
  unavailableStream.destroy()
536
+ expect({value: hub.status().rejectedOpensByCode?.GATEWAY_UNAVAILABLE, context: "the hub status counts the rejected open by its reset code"}).toMatchObject({value: 1})
536
537
 
537
538
  // The route class is healthy, so the reset must not take it out of service.
538
539
  // A member marked unavailable this way is never selected again, which would
@@ -468,6 +468,17 @@ describe("structured logger", {timeoutMs: 15000}, () => {
468
468
  }
469
469
  })
470
470
 
471
+ it("sanitizes the rejected-opens-by-code histogram", () => {
472
+ /** @type {string[]} */
473
+ const lines = []
474
+ const logger = createStructuredLogger({role: "relay", writeLine: (line) => lines.push(line)})
475
+ logger.log("runtime.status", {rejectedOpensByCode: {GATEWAY_UNAVAILABLE: 3, UNKNOWN_GATEWAY_ID: 1}})
476
+ expect(JSON.parse(lines[0] ?? "{}").rejectedOpensByCode).toEqual({GATEWAY_UNAVAILABLE: 3, UNKNOWN_GATEWAY_ID: 1})
477
+
478
+ logger.log("runtime.status", {rejectedOpensByCode: {GATEWAY_UNAVAILABLE: 3, PRIVATE_TOKEN: 2}})
479
+ expect(JSON.parse(lines[1] ?? "{}").rejectedOpensByCode).toBeUndefined()
480
+ })
481
+
471
482
  it("oversized sanitized records fall back to a bounded envelope", () => {
472
483
  /** @type {string[]} */
473
484
  const lines = []