socketduct 0.1.23 → 0.1.24
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
|
+
Reverse gateways now reset their stream high-water mark when a fresh (non-resumed) hub registration completes, so a hub restart or expired resume window no longer wedges the gateway in a protocol-violation reconnect loop that surfaces as `SocketductSessionLostError` on clients.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "socketduct",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
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",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"@eslint/js": "^10.0.1",
|
|
40
40
|
"@types/node": "^24.0.0",
|
|
41
41
|
"@velocious/testing": "0.0.10",
|
|
42
|
+
"awaitery": "^1.0.21",
|
|
42
43
|
"eslint": "^10.0.3",
|
|
43
44
|
"eslint-plugin-jsdoc": "^62.7.1",
|
|
44
45
|
"globals": "^17.2.0",
|
package/src/reverse/gateway.js
CHANGED
|
@@ -294,6 +294,20 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
294
294
|
if (detail.gatewayId !== this.#options.gatewayId || detail.generation !== generation) throw fatalError("GATEWAY_REGISTRATION_FAILED")
|
|
295
295
|
if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_REGISTRATION_FAILED")
|
|
296
296
|
registered = true
|
|
297
|
+
// The hub's reverse stream-ID allocation restarts on every fresh
|
|
298
|
+
// physical: it begins at the retained-stream high-water + 2 (or 2
|
|
299
|
+
// when nothing is retained). #lastStreamId otherwise persists for
|
|
300
|
+
// the gateway process lifetime, so a gateway that reconnects would
|
|
301
|
+
// reject the new physical's valid low-numbered OPENs as "Invalid
|
|
302
|
+
// reverse gateway stream ID", disconnect with protocol_violation,
|
|
303
|
+
// and wedge in a reconnect loop that surfaces on clients as
|
|
304
|
+
// SocketductSessionLostError. Reset the watermark to this
|
|
305
|
+
// physical's allocation base on every (re)registration, resumed or
|
|
306
|
+
// not; retained bridges re-attach via RESUMED (which does not flow
|
|
307
|
+
// through #openStream) and the hub allocates new OPENs strictly
|
|
308
|
+
// above the retained high-water, so the within-physical monotonic
|
|
309
|
+
// guard still holds.
|
|
310
|
+
this.#lastStreamId = 0
|
|
297
311
|
if (detail.resumed) {
|
|
298
312
|
// A replacement gateway intentionally owns no inherited target
|
|
299
313
|
// sockets. Its empty manifest makes the hub reset every omitted
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Listen on an ephemeral port and resolve with the assigned port.
|
|
5
|
+
* @param {import('node:net').Server} server - Server to listen on.
|
|
6
|
+
* @returns {Promise<number>} The assigned TCP port.
|
|
7
|
+
*/
|
|
8
|
+
export async function listen(server) {
|
|
9
|
+
await new Promise((resolve, reject) => {
|
|
10
|
+
server.once("error", reject)
|
|
11
|
+
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
|
|
12
|
+
})
|
|
13
|
+
const address = server.address()
|
|
14
|
+
assert(address !== null && typeof address !== "string")
|
|
15
|
+
return address.port
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the first time an event is emitted; reject on an "error" event.
|
|
20
|
+
* @param {import('node:events').EventEmitter} emitter - Emitter to watch.
|
|
21
|
+
* @param {string} event - Event name to wait for.
|
|
22
|
+
* @returns {Promise<void>}
|
|
23
|
+
*/
|
|
24
|
+
export async function onceEvent(emitter, event) {
|
|
25
|
+
await new Promise((resolve, reject) => {
|
|
26
|
+
emitter.once("error", reject)
|
|
27
|
+
emitter.once(event, () => { emitter.off("error", reject); resolve(undefined) })
|
|
28
|
+
})
|
|
29
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import {mkdtemp, rm} from "node:fs/promises"
|
|
3
|
+
import {createServer} from "node:net"
|
|
4
|
+
import {tmpdir} from "node:os"
|
|
5
|
+
import {join} from "node:path"
|
|
6
|
+
import test from "node:test"
|
|
7
|
+
|
|
8
|
+
import {StaticTokenStore} from "../../src/auth/static-token-store.js"
|
|
9
|
+
import {withTimeout, waitUntil} from "awaitery"
|
|
10
|
+
import {OutboundReverseGateway, ReverseGatewayConnection, startReverseGatewayHub} from "../../src/reverse/index.js"
|
|
11
|
+
import {generateTestCertificates} from "../helpers/tls-certificates.js"
|
|
12
|
+
import {listen, onceEvent} from "../helpers/test-async.js"
|
|
13
|
+
|
|
14
|
+
test("gateway resets its reverse stream high-water on fresh registration after a hub restart", async (t) => {
|
|
15
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-streamid-"))
|
|
16
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
17
|
+
const certificates = generateTestCertificates(root)
|
|
18
|
+
|
|
19
|
+
let targetAccepts = 0
|
|
20
|
+
const targetSockets = new Set()
|
|
21
|
+
const target = createServer((socket) => {
|
|
22
|
+
targetAccepts += 1
|
|
23
|
+
targetSockets.add(socket)
|
|
24
|
+
socket.once("close", () => targetSockets.delete(socket))
|
|
25
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
26
|
+
})
|
|
27
|
+
const targetPort = await listen(target)
|
|
28
|
+
t.after(() => {
|
|
29
|
+
for (const socket of targetSockets) socket.destroy()
|
|
30
|
+
return new Promise((resolve) => target.close(() => resolve(undefined)))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const tokens = new StaticTokenStore([
|
|
34
|
+
{username: "home-o2", token: "gateway-token"},
|
|
35
|
+
{username: "tensorbuzz", token: "client-token"}
|
|
36
|
+
])
|
|
37
|
+
const hubOptions = {
|
|
38
|
+
tokens,
|
|
39
|
+
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
40
|
+
knownGatewayIds: ["o2"],
|
|
41
|
+
authorizeGateway: (/** @type {{principal: {username: string}, gatewayId: string}} */ {principal, gatewayId}) => principal.username === "home-o2" && gatewayId === "o2",
|
|
42
|
+
authorizeClientGateway: (/** @type {{principal: {username: string}, gatewayId: string}} */ {principal, gatewayId}) => principal.username === "tensorbuzz" && gatewayId === "o2",
|
|
43
|
+
authorizeOpen: (/** @type {{principal: {username: string}, gatewayId: string, target: {host: string, port: number}}} */ {principal, gatewayId, target: requested}) => principal.username === "tensorbuzz" && gatewayId === "o2" && requested.host === "127.0.0.1" && requested.port === targetPort
|
|
44
|
+
}
|
|
45
|
+
let hub = await startReverseGatewayHub(hubOptions)
|
|
46
|
+
t.after(() => hub.close())
|
|
47
|
+
|
|
48
|
+
const gateway = new OutboundReverseGateway({
|
|
49
|
+
gatewayId: "o2",
|
|
50
|
+
connectionId: "streamid_gateway",
|
|
51
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
|
|
52
|
+
tls: {caFile: certificates.caCertFile, servername: "localhost"},
|
|
53
|
+
authorizeTarget: (requested) => requested.host === "127.0.0.1" && requested.port === targetPort,
|
|
54
|
+
reconnectDelayMs: 10,
|
|
55
|
+
maxReconnectDelayMs: 20
|
|
56
|
+
})
|
|
57
|
+
t.after(() => gateway.close())
|
|
58
|
+
await withTimeout(gateway.start(), "gateway registration")
|
|
59
|
+
|
|
60
|
+
const client = new ReverseGatewayConnection({
|
|
61
|
+
gatewayId: "o2",
|
|
62
|
+
connectionId: "streamid_client",
|
|
63
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
|
|
64
|
+
tls: {caFile: certificates.caCertFile, servername: "localhost"},
|
|
65
|
+
reconnectDelayMs: 10,
|
|
66
|
+
maxReconnectDelayMs: 20
|
|
67
|
+
})
|
|
68
|
+
t.after(() => client.close())
|
|
69
|
+
await withTimeout(client.connect(), "client authentication")
|
|
70
|
+
|
|
71
|
+
// Open the first stream: this raises the gateway's process-lifetime
|
|
72
|
+
// #lastStreamId high-water mark above the fresh registration's start.
|
|
73
|
+
const first = client.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "before-restart"})
|
|
74
|
+
await withTimeout(onceEvent(first, "connect"), "first reverse stream opens")
|
|
75
|
+
first.write("first")
|
|
76
|
+
await new Promise((resolve, reject) => {
|
|
77
|
+
first.once("data", (/** @type {Buffer} */ chunk) => { assert.equal(chunk.toString(), "first"); resolve(undefined) })
|
|
78
|
+
first.once("error", reject)
|
|
79
|
+
})
|
|
80
|
+
const firstClosed = onceEvent(first, "close")
|
|
81
|
+
first.end()
|
|
82
|
+
await withTimeout(firstClosed, "first stream close")
|
|
83
|
+
// Fully retire the first stream on every peer before the hub restarts so
|
|
84
|
+
// the only effect of the restart is the fresh (non-resumed) registration.
|
|
85
|
+
await waitUntil(() => client.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && hub.status().virtualStreams === 0, "first stream retired")
|
|
86
|
+
|
|
87
|
+
// Restart the hub so the gateway reconnects with a fresh (non-resumed)
|
|
88
|
+
// registration whose stream ids restart from the low end.
|
|
89
|
+
const port = hub.port
|
|
90
|
+
await withTimeout(hub.close(), "hub close")
|
|
91
|
+
hub = await startReverseGatewayHub({...hubOptions, port})
|
|
92
|
+
t.after(() => hub.close())
|
|
93
|
+
await waitUntil(() => gateway.status().physicalState === "ready" && client.status().physicalState === "ready" && gateway.status().physicalConnects >= 2 && client.status().physicalConnects >= 2, "peers after hub restart")
|
|
94
|
+
|
|
95
|
+
// Without the high-water reset, this OPEN is rejected as
|
|
96
|
+
// "Invalid reverse gateway stream ID" and the gateway wedges.
|
|
97
|
+
const second = client.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "after-restart"})
|
|
98
|
+
await withTimeout(onceEvent(second, "connect"), "second reverse stream opens after hub restart")
|
|
99
|
+
second.write("second")
|
|
100
|
+
await new Promise((resolve, reject) => {
|
|
101
|
+
second.once("data", (/** @type {Buffer} */ chunk) => { assert.equal(chunk.toString(), "second"); resolve(undefined) })
|
|
102
|
+
second.once("error", reject)
|
|
103
|
+
})
|
|
104
|
+
const secondClosed = onceEvent(second, "close")
|
|
105
|
+
second.end()
|
|
106
|
+
await withTimeout(secondClosed, "second stream close")
|
|
107
|
+
|
|
108
|
+
assert.ok(targetAccepts >= 2, `expected at least two target connections, got ${targetAccepts}`)
|
|
109
|
+
})
|