socketduct 0.1.15
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/.dockerignore +11 -0
- package/.env.example +131 -0
- package/AGENTS.md +168 -0
- package/Dockerfile +30 -0
- package/README.md +134 -0
- package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
- package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
- package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
- package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
- package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
- package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
- package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
- package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
- package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
- package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
- package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
- package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
- package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
- package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
- package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
- package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
- package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
- package/changelog.d/20260902231542-reverse-close-position.md +1 -0
- package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
- package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
- package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
- package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
- package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
- package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
- package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
- package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
- package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
- package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
- package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
- package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
- package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
- package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
- package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
- package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
- package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
- package/docker-compose.dev.yml +19 -0
- package/docker-compose.reverse-gateway.yml +36 -0
- package/docker-compose.yml +73 -0
- package/docs/architecture.md +264 -0
- package/docs/docker-topology.md +164 -0
- package/docs/docker.md +281 -0
- package/docs/logging.md +185 -0
- package/docs/plans/initial-implementation.md +205 -0
- package/docs/plans/socket-docker-integration.md +221 -0
- package/docs/protocol.md +558 -0
- package/docs/releasing.md +18 -0
- package/docs/reverse-gateway.md +186 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
- package/docs/running.md +215 -0
- package/docs/security.md +258 -0
- package/docs/session-lifecycle.md +127 -0
- package/docs/socket-api.md +448 -0
- package/eslint.config.js +36 -0
- package/package.json +44 -0
- package/scripts/deploy-gateway.js +194 -0
- package/scripts/test.js +28 -0
- package/src/auth/passwords.js +38 -0
- package/src/auth/static-token-store.js +41 -0
- package/src/auth/user-store.js +139 -0
- package/src/client/connect.js +76 -0
- package/src/client/deferred-virtual-socket.js +312 -0
- package/src/client/http-agent.js +101 -0
- package/src/client/https-agent.js +366 -0
- package/src/client/multiplexed-connection.js +1504 -0
- package/src/client/multiplexed-transport-set.js +917 -0
- package/src/client/multiplexed.js +3 -0
- package/src/client/socketduct-socket.js +1174 -0
- package/src/client/virtual-socket.js +256 -0
- package/src/config/environment.js +400 -0
- package/src/edge/tcp-edge.js +82 -0
- package/src/index.js +101 -0
- package/src/logging/structured-logger.js +745 -0
- package/src/protocol/compression.js +325 -0
- package/src/protocol/frame.js +341 -0
- package/src/protocol/v3-compression.js +44 -0
- package/src/protocol/v3-frame.js +209 -0
- package/src/proxy/multiplexed-relay.js +798 -0
- package/src/proxy/tcp-relay.js +1191 -0
- package/src/relay.js +3 -0
- package/src/reverse/client.js +721 -0
- package/src/reverse/common.js +88 -0
- package/src/reverse/gateway.js +663 -0
- package/src/reverse/hub.js +563 -0
- package/src/reverse/index.js +3 -0
- package/src/reverse/runtime.js +78 -0
- package/src/server/control-handler.js +170 -0
- package/src/server/primary.js +64 -0
- package/src/server/relay-runtime.js +124 -0
- package/src/server/worker.js +39 -0
- package/src/sessions/session.js +212 -0
- package/src/spool/spool-file.js +451 -0
- package/tensorbuzz.yml +36 -0
- package/test/e2e/gateway-process.test.js +262 -0
- package/test/e2e/reverse-gateway-process.test.js +285 -0
- package/test/fixtures/logging/enospc-matrix.mjs +238 -0
- package/test/fixtures/logging/enospc-recovery.mjs +146 -0
- package/test/fixtures/logging/process-writer.mjs +20 -0
- package/test/helpers/tls-certificates.js +115 -0
- package/test/integration/initial-target-recovery.test.js +518 -0
- package/test/integration/logging-recovery.test.js +163 -0
- package/test/integration/multiplexed-compatibility.test.js +254 -0
- package/test/integration/multiplexed-corrections.test.js +551 -0
- package/test/integration/multiplexed-failures.test.js +339 -0
- package/test/integration/multiplexed-transport-set.test.js +456 -0
- package/test/integration/multiplexed-transport.test.js +208 -0
- package/test/integration/reverse-gateway.test.js +1293 -0
- package/test/integration/socketduct-http-agent.test.js +435 -0
- package/test/integration/socketduct-https-agent.test.js +669 -0
- package/test/integration/stream-compression.test.js +1267 -0
- package/test/integration/tcp-forwarding.test.js +2878 -0
- package/test/integration/tls-forwarding.test.js +748 -0
- package/test/unit/auth/auth.test.js +64 -0
- package/test/unit/auth/static-token-store.test.js +14 -0
- package/test/unit/client/connect.test.js +58 -0
- package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
- package/test/unit/client/multiplexed-transport-set.test.js +906 -0
- package/test/unit/client/socketduct-socket.test.js +1124 -0
- package/test/unit/client/virtual-socket.test.js +98 -0
- package/test/unit/config/docker-gateway-files.test.js +274 -0
- package/test/unit/config/environment.test.js +507 -0
- package/test/unit/index.test.js +78 -0
- package/test/unit/logging/structured-logger.test.js +674 -0
- package/test/unit/protocol/compression.test.js +421 -0
- package/test/unit/protocol/frame.test.js +336 -0
- package/test/unit/protocol/v3-compression.test.js +31 -0
- package/test/unit/protocol/v3-frame.test.js +57 -0
- package/test/unit/release-configuration.test.js +21 -0
- package/test/unit/server/control-handler.test.js +291 -0
- package/test/unit/server/primary.test.js +59 -0
- package/test/unit/server/relay-runtime.test.js +434 -0
- package/test/unit/sessions/session.test.js +84 -0
- package/test/unit/spool/spool-file.test.js +295 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {negotiateCodec} from "../protocol/compression.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('../sessions/session.js').SessionStore} SessionStore
|
|
5
|
+
* @typedef {{host: string, port: number}} SessionTarget
|
|
6
|
+
* @typedef {{userId: string, username: string}} Principal
|
|
7
|
+
* @typedef {object} TokenAuthenticator
|
|
8
|
+
* @property {(value: string) => Principal | undefined} authenticate - Authenticate a bearer token.
|
|
9
|
+
* @typedef {object} ControlHandlerOptions
|
|
10
|
+
* @property {TokenAuthenticator} tokens - Authentication token store.
|
|
11
|
+
* @property {SessionStore} sessions - Session store.
|
|
12
|
+
* @property {(input: {principal: Principal, target: SessionTarget}) => boolean} [authorizeTarget] - Target authorization policy.
|
|
13
|
+
* @property {string[]} [supportedCodecs] - stream.data codecs this relay accepts, in preference order. Empty or absent disables compression.
|
|
14
|
+
* @property {number[]} [supportedFraming] - Compact framing versions accepted by the relay.
|
|
15
|
+
* @typedef {Record<string, unknown> & {type: string}} ControlMessage
|
|
16
|
+
* @typedef {Record<string, unknown> & {type: string}} ControlResponse
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {ControlHandlerOptions} options - Handler dependencies.
|
|
21
|
+
* @param {ControlMessage} message - Incoming control message.
|
|
22
|
+
* @returns {ControlResponse} - Machine-readable response.
|
|
23
|
+
*/
|
|
24
|
+
export function handleControlMessage(options, message) {
|
|
25
|
+
if (message.type === "session.create") {
|
|
26
|
+
const framingError = requireFramingV2(message.framing, options.supportedFraming ?? [])
|
|
27
|
+
if (framingError !== undefined) {
|
|
28
|
+
return framingError
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const principal = authenticate(options.tokens, message.token)
|
|
32
|
+
if (!principal) {
|
|
33
|
+
return errorResponse("AUTH_REQUIRED", "Authentication is required.", false)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof message.name !== "string" || message.name.length === 0) {
|
|
37
|
+
return errorResponse("PROTOCOL_VIOLATION", "Session name is required.", false)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const target = parseTarget(message.target)
|
|
41
|
+
if (target === undefined) {
|
|
42
|
+
return errorResponse("PROTOCOL_VIOLATION", "Target host and port are required.", false)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (options.authorizeTarget?.({principal, target}) !== true) {
|
|
46
|
+
return errorResponse("TARGET_UNAUTHORIZED", "Target is not authorized for this principal.", false)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @type {ReturnType<SessionStore['create']>} */
|
|
50
|
+
let session
|
|
51
|
+
try {
|
|
52
|
+
session = options.sessions.create({
|
|
53
|
+
name: message.name,
|
|
54
|
+
ownerId: principal.userId,
|
|
55
|
+
target,
|
|
56
|
+
...(typeof message.identifier === "string" ? {identifier: message.identifier} : {})
|
|
57
|
+
})
|
|
58
|
+
} catch {
|
|
59
|
+
return errorResponse("SESSION_EXISTS", "Session identifier already exists.", false)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const createdCodec = negotiateCodec(message.compression, options.supportedCodecs ?? [])
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
type: "session.created",
|
|
66
|
+
identifier: session.identifier,
|
|
67
|
+
name: session.name,
|
|
68
|
+
state: session.state,
|
|
69
|
+
target: session.target,
|
|
70
|
+
...(createdCodec !== undefined ? {compression: createdCodec} : {}),
|
|
71
|
+
framing: 2
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (message.type === "session.resume") {
|
|
76
|
+
const framingError = requireFramingV2(message.framing, options.supportedFraming ?? [])
|
|
77
|
+
if (framingError !== undefined) {
|
|
78
|
+
return framingError
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const principal = authenticate(options.tokens, message.token)
|
|
82
|
+
if (!principal) {
|
|
83
|
+
return errorResponse("AUTH_REQUIRED", "Authentication is required.", false)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof message.identifier !== "string" || message.identifier.length === 0) {
|
|
87
|
+
return errorResponse("PROTOCOL_VIOLATION", "Session identifier is required.", false)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const session = options.sessions.resume({identifier: message.identifier, ownerId: principal.userId})
|
|
92
|
+
const resumedCodec = negotiateCodec(message.compression, options.supportedCodecs ?? [])
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
type: "session.resumed",
|
|
96
|
+
identifier: session.identifier,
|
|
97
|
+
name: session.name,
|
|
98
|
+
state: session.state,
|
|
99
|
+
target: session.target,
|
|
100
|
+
serverLastSequence: session.relaySequence,
|
|
101
|
+
serverLastAck: session.clientSequence,
|
|
102
|
+
replayFromSequence: session.clientSequence + 1,
|
|
103
|
+
...(resumedCodec !== undefined ? {compression: resumedCodec} : {}),
|
|
104
|
+
framing: 2
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const messageText = error instanceof Error ? error.message : String(error)
|
|
108
|
+
const code = messageText.includes("expired") ? "SESSION_EXPIRED" : "SESSION_NOT_FOUND"
|
|
109
|
+
return errorResponse(code, "Session does not exist or is not owned by this user.", false)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return errorResponse("PROTOCOL_VIOLATION", `Unsupported message type: ${message.type}`, false)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {unknown} offer - Client framing offer.
|
|
118
|
+
* @param {number[]} supported - Relay framing versions.
|
|
119
|
+
* @returns {ControlResponse | undefined} - Error when framing v2 cannot be negotiated.
|
|
120
|
+
*/
|
|
121
|
+
function requireFramingV2(offer, supported) {
|
|
122
|
+
if (Array.isArray(offer) && offer.includes(2) && supported.includes(2)) {
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
return errorResponse("PROTOCOL_VIOLATION", "Socketduct protocol framing version 2 is required.", false)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {TokenAuthenticator} tokens - Token store.
|
|
130
|
+
* @param {unknown} token - Token value.
|
|
131
|
+
* @returns {Principal | undefined} - Authenticated principal.
|
|
132
|
+
*/
|
|
133
|
+
function authenticate(tokens, token) {
|
|
134
|
+
if (typeof token !== "string") {
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
return tokens.authenticate(token)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {unknown} value - Incoming target field.
|
|
142
|
+
* @returns {SessionTarget | undefined} - Validated target.
|
|
143
|
+
*/
|
|
144
|
+
function parseTarget(value) {
|
|
145
|
+
if (typeof value !== "object" || value === null) {
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const candidate = /** @type {{host?: unknown, port?: unknown}} */ (value)
|
|
150
|
+
if (typeof candidate.host !== "string" || candidate.host.length === 0) {
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const port = candidate.port
|
|
155
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {host: candidate.host, port}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @param {string} code - Error code.
|
|
164
|
+
* @param {string} message - Error message.
|
|
165
|
+
* @param {boolean} retryable - Retry hint.
|
|
166
|
+
* @returns {ControlResponse} - Error response.
|
|
167
|
+
*/
|
|
168
|
+
export function errorResponse(code, message, retryable) {
|
|
169
|
+
return {type: "error", code, message, retryable}
|
|
170
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import cluster from "node:cluster"
|
|
2
|
+
import {availableParallelism} from "node:os"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} ClusterLike
|
|
6
|
+
* @property {boolean} isPrimary - Whether this process is the primary process.
|
|
7
|
+
* @property {(env?: Record<string, string>) => WorkerLike} fork - Forks a worker process.
|
|
8
|
+
* @property {(event: 'exit', listener: (worker: WorkerLike, code: number | null, signal: string | null) => void) => void} on - Registers a cluster event listener.
|
|
9
|
+
* @typedef {object} WorkerLike
|
|
10
|
+
* @property {number | string | undefined} [id] - Worker identifier.
|
|
11
|
+
* @typedef {object} LoggerLike
|
|
12
|
+
* @property {(message: string) => void} [info] - Info logger.
|
|
13
|
+
* @property {(message: string) => void} [warn] - Warning logger.
|
|
14
|
+
* @typedef {object} PrimaryOptions
|
|
15
|
+
* @property {number} [workers] - Configured worker count.
|
|
16
|
+
* @property {ClusterLike} [clusterApi] - Cluster API implementation.
|
|
17
|
+
* @property {LoggerLike} [logger] - Logger implementation.
|
|
18
|
+
* @property {() => number} [availableCpus] - CPU-count provider.
|
|
19
|
+
* @typedef {object} PrimaryStartResult
|
|
20
|
+
* @property {'primary' | 'worker'} role - Process role.
|
|
21
|
+
* @property {number} workers - Worker count.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {number | undefined} configuredWorkers - Explicit worker count.
|
|
26
|
+
* @param {() => number} [availableCpus] - CPU-count provider.
|
|
27
|
+
* @returns {number} - Safe worker count.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveWorkerCount(configuredWorkers, availableCpus = availableParallelism) {
|
|
30
|
+
if (configuredWorkers !== undefined) {
|
|
31
|
+
if (!Number.isInteger(configuredWorkers) || configuredWorkers < 1) {
|
|
32
|
+
throw new Error(`Invalid worker count: ${configuredWorkers}`)
|
|
33
|
+
}
|
|
34
|
+
return configuredWorkers
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return Math.max(1, availableCpus())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {PrimaryOptions} [options] - Primary start options.
|
|
42
|
+
* @returns {PrimaryStartResult} - Start summary.
|
|
43
|
+
*/
|
|
44
|
+
export function startPrimary(options = {}) {
|
|
45
|
+
const clusterApi = options.clusterApi ?? cluster
|
|
46
|
+
const logger = options.logger ?? console
|
|
47
|
+
const workers = resolveWorkerCount(options.workers, options.availableCpus)
|
|
48
|
+
|
|
49
|
+
if (!clusterApi.isPrimary) {
|
|
50
|
+
return {role: "worker", workers: 0}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (let index = 0; index < workers; index += 1) {
|
|
54
|
+
clusterApi.fork({SOCKETDUCT_WORKER_INDEX: String(index)})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
clusterApi.on("exit", (worker, code, signal) => {
|
|
58
|
+
logger.warn?.(`Socketduct worker ${String(worker.id ?? "unknown")} exited with code ${String(code)} signal ${String(signal)}; restarting`)
|
|
59
|
+
clusterApi.fork()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
logger.info?.(`Socketduct primary started ${workers} worker(s)`)
|
|
63
|
+
return {role: "primary", workers}
|
|
64
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {StaticTokenStore} from "../auth/static-token-store.js"
|
|
2
|
+
import {isLoopbackHost} from "../config/environment.js"
|
|
3
|
+
import {startTcpRelay} from "../proxy/tcp-relay.js"
|
|
4
|
+
import {startMultiplexedTcpRelay} from "../proxy/multiplexed-relay.js"
|
|
5
|
+
import {SessionStore} from "../sessions/session.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {import('../config/environment.js').RelayEnvironmentConfig} RelayEnvironmentConfig
|
|
9
|
+
* @typedef {import('../config/environment.js').TargetEndpoint} TargetEndpoint
|
|
10
|
+
* @typedef {object} RelayRuntimeHandle
|
|
11
|
+
* @property {{server: import('node:net').Server | import('node:tls').Server, port: number, status: () => object, close: () => Promise<void>}} relay - Relay server handle.
|
|
12
|
+
* @property {() => Promise<void>} close - Close function.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {RelayEnvironmentConfig} config - Relay runtime configuration.
|
|
17
|
+
* @returns {Promise<RelayRuntimeHandle>} Started relay runtime.
|
|
18
|
+
*/
|
|
19
|
+
export async function startRelayRuntime(config) {
|
|
20
|
+
validateRelayConfig(config)
|
|
21
|
+
|
|
22
|
+
const host = config.relayHost
|
|
23
|
+
const publicBind = !isLoopbackHost(host)
|
|
24
|
+
const tlsEnabled = config.relayTls !== undefined
|
|
25
|
+
|
|
26
|
+
if (publicBind && !tlsEnabled && !config.allowInsecurePublic) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Relay binds to public host ${host} without TLS. Set SOCKETDUCT_TLS_CERT_FILE + SOCKETDUCT_TLS_KEY_FILE, or explicitly opt out with SOCKETDUCT_ALLOW_INSECURE_PUBLIC=1.`
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @type {{certFile: string, keyFile: string, clientCaFile?: string} | undefined} */
|
|
33
|
+
let tlsOptions
|
|
34
|
+
if (tlsEnabled) {
|
|
35
|
+
const tls = /** @type {import('../config/environment.js').RelayTlsConfig} */ (config.relayTls)
|
|
36
|
+
/** @type {{certFile: string, keyFile: string, clientCaFile?: string}} */
|
|
37
|
+
const opts = {certFile: tls.certFile, keyFile: tls.keyFile}
|
|
38
|
+
if (tls.clientCaFile !== undefined) {
|
|
39
|
+
opts.clientCaFile = tls.clientCaFile
|
|
40
|
+
}
|
|
41
|
+
tlsOptions = opts
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tokens = new StaticTokenStore(config.authTokens)
|
|
45
|
+
const sessions = new SessionStore()
|
|
46
|
+
const allowedTargets = new Set(config.targetAllowlist.map(formatTarget))
|
|
47
|
+
/** @type {import('../protocol/compression.js').CompressionLimits} */
|
|
48
|
+
const compressionLimits = {}
|
|
49
|
+
if (config.minCompressBytes !== undefined) {
|
|
50
|
+
compressionLimits.minCompressBytes = config.minCompressBytes
|
|
51
|
+
}
|
|
52
|
+
if (config.maxDecompressedBytes !== undefined) {
|
|
53
|
+
compressionLimits.maxDecompressedBytes = config.maxDecompressedBytes
|
|
54
|
+
}
|
|
55
|
+
if (config.maxExpansionRatio !== undefined) {
|
|
56
|
+
compressionLimits.maxExpansionRatio = config.maxExpansionRatio
|
|
57
|
+
}
|
|
58
|
+
if (config.protocolVersion === 3) {
|
|
59
|
+
const relay = await startMultiplexedTcpRelay({
|
|
60
|
+
host,
|
|
61
|
+
port: config.relayPort,
|
|
62
|
+
resumeWindowMs: config.resumeWindowMs,
|
|
63
|
+
maxStreamBufferBytes: config.maxTargetBufferBytes,
|
|
64
|
+
maxConnectionBufferBytes: config.maxTotalBufferBytes,
|
|
65
|
+
maxTotalBufferBytes: config.maxTotalBufferBytes,
|
|
66
|
+
handshakeTimeoutMs: config.handshakeTimeoutMs,
|
|
67
|
+
maxInputBytesPerConnection: config.maxInputBytesPerConnection,
|
|
68
|
+
...(config.initialTargetConnectTimeoutMs === undefined ? {} : {targetConnectTimeoutMs: config.initialTargetConnectTimeoutMs}),
|
|
69
|
+
tokens,
|
|
70
|
+
authorizeTarget: ({target}) => allowedTargets.has(formatTarget(target)),
|
|
71
|
+
...(tlsOptions === undefined ? {} : {tls: tlsOptions})
|
|
72
|
+
})
|
|
73
|
+
return {relay, close: relay.close}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @type {import('../proxy/tcp-relay.js').TcpRelayOptions} */
|
|
77
|
+
const relayOptions = {
|
|
78
|
+
host,
|
|
79
|
+
port: config.relayPort,
|
|
80
|
+
resumeWindowMs: config.resumeWindowMs,
|
|
81
|
+
maxTargetBufferBytes: config.maxTargetBufferBytes,
|
|
82
|
+
maxTotalBufferBytes: config.maxTotalBufferBytes,
|
|
83
|
+
...(config.initialTargetRetryDelayMs === undefined ? {} : {initialTargetRetryDelayMs: config.initialTargetRetryDelayMs}),
|
|
84
|
+
...(config.initialTargetRetryMaxDelayMs === undefined ? {} : {initialTargetRetryMaxDelayMs: config.initialTargetRetryMaxDelayMs}),
|
|
85
|
+
...(config.initialTargetConnectTimeoutMs === undefined ? {} : {initialTargetConnectTimeoutMs: config.initialTargetConnectTimeoutMs}),
|
|
86
|
+
handshakeTimeoutMs: config.handshakeTimeoutMs,
|
|
87
|
+
maxInputBytesPerConnection: config.maxInputBytesPerConnection,
|
|
88
|
+
compressionEnabled: config.compression !== "off",
|
|
89
|
+
compressionLimits,
|
|
90
|
+
tokens,
|
|
91
|
+
sessions,
|
|
92
|
+
authorizeTarget: ({target}) => allowedTargets.has(formatTarget(target))
|
|
93
|
+
}
|
|
94
|
+
if (tlsOptions !== undefined) {
|
|
95
|
+
relayOptions.tls = tlsOptions
|
|
96
|
+
}
|
|
97
|
+
const relay = await startTcpRelay(relayOptions)
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
relay,
|
|
101
|
+
close: relay.close
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {RelayEnvironmentConfig} config - Relay runtime configuration.
|
|
107
|
+
* @returns {void}
|
|
108
|
+
*/
|
|
109
|
+
function validateRelayConfig(config) {
|
|
110
|
+
if (config.authTokens.length === 0) {
|
|
111
|
+
throw new Error("SOCKETDUCT_AUTH_TOKENS must contain at least one username:token entry in relay mode")
|
|
112
|
+
}
|
|
113
|
+
if (config.targetAllowlist.length === 0) {
|
|
114
|
+
throw new Error("SOCKETDUCT_TARGET_ALLOWLIST must contain at least one host:port entry in relay mode")
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {TargetEndpoint} target - Target endpoint.
|
|
120
|
+
* @returns {string} Canonical allowlist key.
|
|
121
|
+
*/
|
|
122
|
+
function formatTarget(target) {
|
|
123
|
+
return `${target.host}:${target.port}`
|
|
124
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {createServer} from "node:net"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {object} WorkerServerOptions
|
|
5
|
+
* @property {string} [host] - Listen host.
|
|
6
|
+
* @property {number} [port] - Listen port.
|
|
7
|
+
* @property {(socket: import('node:net').Socket) => void} connectionHandler - Socket connection handler.
|
|
8
|
+
* @typedef {object} WorkerServerHandle
|
|
9
|
+
* @property {import('node:net').Server} server - TCP server.
|
|
10
|
+
* @property {() => Promise<void>} close - Close function.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {WorkerServerOptions} options - Worker server options.
|
|
15
|
+
* @returns {Promise<WorkerServerHandle>} - Started worker server.
|
|
16
|
+
*/
|
|
17
|
+
export async function startWorkerServer(options) {
|
|
18
|
+
const server = createServer(options.connectionHandler)
|
|
19
|
+
await new Promise((resolve, reject) => {
|
|
20
|
+
server.once("error", reject)
|
|
21
|
+
server.listen(options.port ?? 0, options.host ?? "127.0.0.1", () => {
|
|
22
|
+
server.off("error", reject)
|
|
23
|
+
resolve(undefined)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
server,
|
|
29
|
+
close: () => new Promise((resolve, reject) => {
|
|
30
|
+
server.close((error) => {
|
|
31
|
+
if (error) {
|
|
32
|
+
reject(error)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
resolve()
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {randomBytes} from "node:crypto"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {'creating' | 'active' | 'interrupted' | 'resuming' | 'closed' | 'expired'} SessionState
|
|
5
|
+
* @typedef {object} Session
|
|
6
|
+
* @property {string} identifier - identifier value.
|
|
7
|
+
* @property {string} name - name value.
|
|
8
|
+
* @property {string} ownerId - ownerId value.
|
|
9
|
+
* @property {SessionState} state - state value.
|
|
10
|
+
* @property {number} clientSequence - clientSequence value.
|
|
11
|
+
* @property {number} relaySequence - relaySequence value.
|
|
12
|
+
* @property {{host: string, port: number}} target - Target endpoint for this session.
|
|
13
|
+
* @property {number} createdAt - createdAt value.
|
|
14
|
+
* @property {number} updatedAt - updatedAt value.
|
|
15
|
+
* @property {number} [expiresAt] - expiresAt value.
|
|
16
|
+
* @typedef {object} CreateSessionInput
|
|
17
|
+
* @property {string} name - name value.
|
|
18
|
+
* @property {string} ownerId - ownerId value.
|
|
19
|
+
* @property {{host: string, port: number}} target - Target endpoint for this session.
|
|
20
|
+
* @property {string} [identifier] - identifier value.
|
|
21
|
+
* @property {number} [clientSequence] - clientSequence value.
|
|
22
|
+
* @property {number} [relaySequence] - relaySequence value.
|
|
23
|
+
* @property {number} [createdAt] - createdAt value.
|
|
24
|
+
* @property {number} [updatedAt] - updatedAt value.
|
|
25
|
+
* @property {number} [expiresAt] - expiresAt value.
|
|
26
|
+
* @typedef {object} ResumeSessionInput
|
|
27
|
+
* @property {string} identifier - identifier value.
|
|
28
|
+
* @property {string} ownerId - ownerId value.
|
|
29
|
+
* @typedef {object} SessionStoreOptions
|
|
30
|
+
* @property {() => number} [now] - now value.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** @type {Map<SessionState, Set<SessionState>>} */
|
|
34
|
+
const LEGAL_TRANSITIONS = new Map([
|
|
35
|
+
["creating", new Set(["active", "closed"])],
|
|
36
|
+
["active", new Set(["interrupted", "closed", "expired"])],
|
|
37
|
+
["interrupted", new Set(["resuming", "closed", "expired"])],
|
|
38
|
+
["resuming", new Set(["active", "interrupted", "closed", "expired"])],
|
|
39
|
+
["closed", new Set()],
|
|
40
|
+
["expired", new Set()]
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {CreateSessionInput} input - input value.
|
|
45
|
+
* @returns {Session} - Result value.
|
|
46
|
+
*/
|
|
47
|
+
export function createSession(input) {
|
|
48
|
+
const now = input.createdAt ?? Date.now()
|
|
49
|
+
return {
|
|
50
|
+
identifier: input.identifier ?? generateSessionIdentifier(),
|
|
51
|
+
name: input.name,
|
|
52
|
+
ownerId: input.ownerId,
|
|
53
|
+
state: "creating",
|
|
54
|
+
clientSequence: input.clientSequence ?? 0,
|
|
55
|
+
relaySequence: input.relaySequence ?? 0,
|
|
56
|
+
target: input.target,
|
|
57
|
+
createdAt: now,
|
|
58
|
+
updatedAt: input.updatedAt ?? now,
|
|
59
|
+
...(input.expiresAt === undefined ? {} : {expiresAt: input.expiresAt})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {Session} session - session value.
|
|
65
|
+
* @param {SessionState} nextState - nextState value.
|
|
66
|
+
* @param {number} [now] - now value.
|
|
67
|
+
* @returns {Session} - Result value.
|
|
68
|
+
*/
|
|
69
|
+
export function transitionSession(session, nextState, now = Date.now()) {
|
|
70
|
+
const allowed = LEGAL_TRANSITIONS.get(session.state) ?? new Set()
|
|
71
|
+
if (!allowed.has(nextState)) {
|
|
72
|
+
throw new Error(`Illegal session state transition: ${session.state} -> ${nextState}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
...session,
|
|
77
|
+
state: nextState,
|
|
78
|
+
updatedAt: now
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {SessionStore} store - store value.
|
|
84
|
+
* @param {ResumeSessionInput} input - input value.
|
|
85
|
+
* @returns {Session} - Result value.
|
|
86
|
+
*/
|
|
87
|
+
export function resumeSession(store, input) {
|
|
88
|
+
return store.resume(input)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class SessionStore {
|
|
92
|
+
/** @type {Map<string, Session>} */
|
|
93
|
+
#sessions = new Map()
|
|
94
|
+
|
|
95
|
+
/** @type {() => number} */
|
|
96
|
+
#now
|
|
97
|
+
|
|
98
|
+
/** @param {SessionStoreOptions} [options] - Store options. */
|
|
99
|
+
constructor(options = {}) {
|
|
100
|
+
this.#now = options.now ?? Date.now
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {CreateSessionInput} input - input value.
|
|
105
|
+
* @returns {Session} - Result value.
|
|
106
|
+
*/
|
|
107
|
+
create(input) {
|
|
108
|
+
const now = this.#now()
|
|
109
|
+
const session = createSession({
|
|
110
|
+
...input,
|
|
111
|
+
createdAt: input.createdAt ?? now,
|
|
112
|
+
updatedAt: input.updatedAt ?? now
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
if (this.#sessions.has(session.identifier)) {
|
|
116
|
+
throw new Error(`Session already exists: ${session.identifier}`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.#sessions.set(session.identifier, session)
|
|
120
|
+
return session
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {string} identifier - identifier value.
|
|
125
|
+
* @returns {Session | undefined} - Result value.
|
|
126
|
+
*/
|
|
127
|
+
get(identifier) {
|
|
128
|
+
return this.#sessions.get(identifier)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {string} identifier - identifier value.
|
|
133
|
+
* @param {SessionState} nextState - nextState value.
|
|
134
|
+
* @returns {Session} - Result value.
|
|
135
|
+
*/
|
|
136
|
+
transition(identifier, nextState) {
|
|
137
|
+
const session = this.#requireSession(identifier)
|
|
138
|
+
const transitioned = transitionSession(session, nextState, this.#now())
|
|
139
|
+
this.#sessions.set(identifier, transitioned)
|
|
140
|
+
return transitioned
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {string} identifier - Session identifier.
|
|
145
|
+
* @param {{clientSequence?: number, relaySequence?: number}} sequences - Committed sequence positions.
|
|
146
|
+
* @returns {Session} - Updated session.
|
|
147
|
+
*/
|
|
148
|
+
updateSequences(identifier, sequences) {
|
|
149
|
+
const session = this.#requireSession(identifier)
|
|
150
|
+
const updated = {
|
|
151
|
+
...session,
|
|
152
|
+
clientSequence: Math.max(session.clientSequence, sequences.clientSequence ?? session.clientSequence),
|
|
153
|
+
relaySequence: Math.max(session.relaySequence, sequences.relaySequence ?? session.relaySequence),
|
|
154
|
+
updatedAt: this.#now()
|
|
155
|
+
}
|
|
156
|
+
this.#sessions.set(identifier, updated)
|
|
157
|
+
return updated
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {ResumeSessionInput} input - input value.
|
|
162
|
+
* @returns {Session} - Result value.
|
|
163
|
+
*/
|
|
164
|
+
resume(input) {
|
|
165
|
+
const session = this.#requireSession(input.identifier)
|
|
166
|
+
|
|
167
|
+
if (session.expiresAt !== undefined && session.expiresAt <= this.#now()) {
|
|
168
|
+
const expired = {...session, state: /** @type {const} */ ("expired"), updatedAt: this.#now()}
|
|
169
|
+
this.#sessions.set(input.identifier, expired)
|
|
170
|
+
throw new Error(`Session expired: ${input.identifier}`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (session.ownerId !== input.ownerId) {
|
|
174
|
+
throw new Error(`Session does not belong to user: ${input.identifier}`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (session.state === "creating") {
|
|
178
|
+
throw new Error(`Session is not resumable before target readiness: ${input.identifier}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const resumeSource = session.state === "interrupted" ? session : transitionSession(session, "interrupted", this.#now())
|
|
182
|
+
const resumed = transitionSession(resumeSource, "resuming", this.#now())
|
|
183
|
+
this.#sessions.set(input.identifier, resumed)
|
|
184
|
+
return resumed
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {string} identifier - Session identifier.
|
|
189
|
+
* @returns {boolean} - True when the session was in the store.
|
|
190
|
+
*/
|
|
191
|
+
delete(identifier) {
|
|
192
|
+
return this.#sessions.delete(identifier)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {string} identifier - identifier value.
|
|
197
|
+
* @returns {Session} - Result value.
|
|
198
|
+
*/
|
|
199
|
+
#requireSession(identifier) {
|
|
200
|
+
const session = this.#sessions.get(identifier)
|
|
201
|
+
if (!session) {
|
|
202
|
+
throw new Error(`Session not found: ${identifier}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return session
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @returns {string} - Opaque token. */
|
|
210
|
+
function generateSessionIdentifier() {
|
|
211
|
+
return randomBytes(24).toString("base64url")
|
|
212
|
+
}
|