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.
- package/changelog.d/20261027034500-reverse-open-rejection-diagnostics.md +1 -0
- package/package.json +1 -1
- package/src/client/multiplexed-connection.js +1 -0
- package/src/logging/structured-logger.js +25 -5
- package/src/reverse/hub.js +14 -5
- package/test/integration/multiplexed-failures.test.js +5 -1
- package/test/integration/reverse-route-class.test.js +1 -0
- package/test/unit/logging/structured-logger.test.js +11 -0
|
@@ -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
|
@@ -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
|
|
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
|
|
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) {
|
package/src/reverse/hub.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = []
|