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,194 @@
|
|
|
1
|
+
import {spawn} from "node:child_process"
|
|
2
|
+
import {readFile} from "node:fs/promises"
|
|
3
|
+
import {delimiter, dirname, join, resolve} from "node:path"
|
|
4
|
+
import {fileURLToPath, pathToFileURL} from "node:url"
|
|
5
|
+
import {parseEnv} from "node:util"
|
|
6
|
+
|
|
7
|
+
const IMMUTABLE_IMAGE_PATTERN = /^ghcr\.io\/kaspernj\/socketduct@sha256:[0-9a-f]{64}$/
|
|
8
|
+
const PROJECT_DIRECTORY = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
9
|
+
|
|
10
|
+
/** @typedef {Record<string, string | undefined>} ProcessEnvironment */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} CommandOptions
|
|
14
|
+
* @property {boolean} [captureStdout] - Capture stdout instead of inheriting it.
|
|
15
|
+
* @property {ProcessEnvironment} env - Child process environment.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @callback CommandRunner
|
|
20
|
+
* @param {string} command
|
|
21
|
+
* @param {string[]} args
|
|
22
|
+
* @param {CommandOptions} options
|
|
23
|
+
* @returns {Promise<string> | string}
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} command
|
|
28
|
+
* @param {string[]} args
|
|
29
|
+
* @param {CommandOptions} options
|
|
30
|
+
* @returns {Promise<string>} Captured stdout, or an empty string.
|
|
31
|
+
*/
|
|
32
|
+
function runCommand(command, args, {captureStdout = false, env}) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
let stdout = ""
|
|
35
|
+
const child = spawn(command, args, {
|
|
36
|
+
env,
|
|
37
|
+
stdio: captureStdout ? ["ignore", "pipe", "inherit"] : "inherit"
|
|
38
|
+
})
|
|
39
|
+
if (captureStdout) {
|
|
40
|
+
child.stdout?.setEncoding("utf8")
|
|
41
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk })
|
|
42
|
+
}
|
|
43
|
+
child.once("error", reject)
|
|
44
|
+
child.once("close", (code, signal) => {
|
|
45
|
+
if (code === 0) {
|
|
46
|
+
resolve(stdout)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
const result = signal === null ? `exit code ${code}` : `signal ${signal}`
|
|
50
|
+
reject(new Error(`${command} failed with ${result}`))
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string | undefined} image
|
|
57
|
+
* @returns {string} Validated immutable image reference.
|
|
58
|
+
*/
|
|
59
|
+
export function validateGatewayImage(image) {
|
|
60
|
+
if (image === undefined || !IMMUTABLE_IMAGE_PATTERN.test(image)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
"SOCKETDUCT_IMAGE must be an immutable GHCR image in the form " +
|
|
63
|
+
"ghcr.io/kaspernj/socketduct@sha256:<64 lowercase hex characters>"
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
return image
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} renderedJson - Rendered Compose JSON held only in memory.
|
|
71
|
+
* @param {string} image - Validated immutable image reference.
|
|
72
|
+
* @returns {void}
|
|
73
|
+
*/
|
|
74
|
+
export function validateRenderedGatewayModel(renderedJson, image) {
|
|
75
|
+
/** @type {unknown} */
|
|
76
|
+
let model
|
|
77
|
+
try {
|
|
78
|
+
model = JSON.parse(renderedJson)
|
|
79
|
+
} catch {
|
|
80
|
+
throw new Error("Rendered Compose model is not valid JSON")
|
|
81
|
+
}
|
|
82
|
+
if (typeof model !== "object" || model === null || !("services" in model)) {
|
|
83
|
+
throw new Error("Rendered Compose model does not define socketduct-relay")
|
|
84
|
+
}
|
|
85
|
+
const services = /** @type {{services?: unknown}} */ (model).services
|
|
86
|
+
if (typeof services !== "object" || services === null || !("socketduct-relay" in services)) {
|
|
87
|
+
throw new Error("Rendered Compose model does not define socketduct-relay")
|
|
88
|
+
}
|
|
89
|
+
const serviceMap = /** @type {Record<string, unknown>} */ (services)
|
|
90
|
+
const relay = serviceMap["socketduct-relay"]
|
|
91
|
+
if (typeof relay !== "object" || relay === null) {
|
|
92
|
+
throw new Error("Rendered Compose model has an invalid socketduct-relay service")
|
|
93
|
+
}
|
|
94
|
+
const relayConfig = /** @type {Record<string, unknown>} */ (relay)
|
|
95
|
+
if (Object.hasOwn(relayConfig, "build")) {
|
|
96
|
+
throw new Error("Rendered socketduct-relay service must not contain a build source")
|
|
97
|
+
}
|
|
98
|
+
if (relayConfig.image !== image) {
|
|
99
|
+
throw new Error("Rendered socketduct-relay image does not exactly match validated SOCKETDUCT_IMAGE")
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {ProcessEnvironment} environment - Host environment.
|
|
105
|
+
* @param {string} image - Validated immutable image reference.
|
|
106
|
+
* @param {string} composeFile - Fixed production Compose file.
|
|
107
|
+
* @param {string} envFile - Fixed production interpolation environment file.
|
|
108
|
+
* @returns {ProcessEnvironment} Environment with deterministic Compose selectors.
|
|
109
|
+
*/
|
|
110
|
+
function composeEnvironment(environment, image, composeFile, envFile) {
|
|
111
|
+
return {
|
|
112
|
+
...environment,
|
|
113
|
+
COMPOSE_DISABLE_ENV_FILE: "false",
|
|
114
|
+
COMPOSE_ENV_FILES: envFile,
|
|
115
|
+
COMPOSE_FILE: composeFile,
|
|
116
|
+
COMPOSE_IGNORE_ORPHANS: "false",
|
|
117
|
+
COMPOSE_PATH_SEPARATOR: delimiter,
|
|
118
|
+
COMPOSE_PROFILES: "",
|
|
119
|
+
COMPOSE_PROJECT_NAME: "socketduct-gateway",
|
|
120
|
+
COMPOSE_REMOVE_ORPHANS: "false",
|
|
121
|
+
SOCKETDUCT_IMAGE: image
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {object} options
|
|
127
|
+
* @param {string | undefined} options.image
|
|
128
|
+
* @param {boolean} [options.dockerHost]
|
|
129
|
+
* @param {ProcessEnvironment} [options.environment]
|
|
130
|
+
* @param {string} [options.projectDirectory]
|
|
131
|
+
* @param {CommandRunner} [options.run]
|
|
132
|
+
* @returns {Promise<void>}
|
|
133
|
+
*/
|
|
134
|
+
export async function deployGateway({
|
|
135
|
+
image,
|
|
136
|
+
dockerHost = false,
|
|
137
|
+
environment = process.env,
|
|
138
|
+
projectDirectory = PROJECT_DIRECTORY,
|
|
139
|
+
run = runCommand
|
|
140
|
+
}) {
|
|
141
|
+
const validatedImage = validateGatewayImage(image)
|
|
142
|
+
const projectPath = resolve(projectDirectory)
|
|
143
|
+
const composeFile = join(projectPath, "docker-compose.yml")
|
|
144
|
+
const envFile = join(projectPath, ".env")
|
|
145
|
+
const env = composeEnvironment(environment, validatedImage, composeFile, envFile)
|
|
146
|
+
const compose = [
|
|
147
|
+
"compose",
|
|
148
|
+
"--file", composeFile,
|
|
149
|
+
"--env-file", envFile,
|
|
150
|
+
"--project-directory", projectPath,
|
|
151
|
+
"--project-name", "socketduct-gateway",
|
|
152
|
+
...(dockerHost ? ["--profile", "docker-host"] : [])
|
|
153
|
+
]
|
|
154
|
+
const renderedJson = await run("docker", [...compose, "config", "--format", "json"], {captureStdout: true, env})
|
|
155
|
+
validateRenderedGatewayModel(renderedJson, validatedImage)
|
|
156
|
+
await run("docker", [...compose, "pull"], {env})
|
|
157
|
+
await run("docker", [...compose, "up", "-d", "--wait", "--no-build"], {env})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @returns {Promise<string | undefined>} Configured image reference, if present.
|
|
162
|
+
*/
|
|
163
|
+
async function loadGatewayImage() {
|
|
164
|
+
if (Object.hasOwn(process.env, "SOCKETDUCT_IMAGE")) return process.env.SOCKETDUCT_IMAGE
|
|
165
|
+
try {
|
|
166
|
+
const environment = parseEnv(await readFile(join(PROJECT_DIRECTORY, ".env"), "utf8"))
|
|
167
|
+
return environment.SOCKETDUCT_IMAGE
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined
|
|
170
|
+
throw error
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @returns {Promise<void>}
|
|
176
|
+
*/
|
|
177
|
+
async function main() {
|
|
178
|
+
const arguments_ = process.argv.slice(2)
|
|
179
|
+
const unsupported = arguments_.filter((argument) => argument !== "--docker-host")
|
|
180
|
+
if (unsupported.length > 0 || arguments_.filter((argument) => argument === "--docker-host").length > 1) {
|
|
181
|
+
throw new Error("Usage: npm run gateway:deploy -- [--docker-host]")
|
|
182
|
+
}
|
|
183
|
+
await deployGateway({
|
|
184
|
+
dockerHost: arguments_.includes("--docker-host"),
|
|
185
|
+
image: await loadGatewayImage()
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
190
|
+
main().catch((error) => {
|
|
191
|
+
console.error(error instanceof Error ? error.message : error)
|
|
192
|
+
process.exitCode = 1
|
|
193
|
+
})
|
|
194
|
+
}
|
package/scripts/test.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {spawnSync} from "node:child_process"
|
|
2
|
+
import {globSync} from "node:fs"
|
|
3
|
+
|
|
4
|
+
const migrated = ["test/unit/logging/structured-logger.test.js", "test/integration/logging-recovery.test.js"]
|
|
5
|
+
const files = globSync("test/**/*.test.js").sort()
|
|
6
|
+
for (const path of migrated) {
|
|
7
|
+
if (!files.includes(path)) throw new Error(`Missing migrated test module: ${path}`)
|
|
8
|
+
}
|
|
9
|
+
const legacy = files.filter((path) => !migrated.includes(path))
|
|
10
|
+
if (legacy.length === 0) throw new Error("No legacy test modules discovered")
|
|
11
|
+
const commands = [
|
|
12
|
+
{runner: process.execPath, args: ["--test", ...legacy]},
|
|
13
|
+
...migrated.map((path) => ({runner: "velocious-test", args: [path]}))
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
if (process.argv.length === 3 && process.argv[2] === "--list") {
|
|
17
|
+
console.log(JSON.stringify({legacy, migrated, commands}, null, 2))
|
|
18
|
+
} else {
|
|
19
|
+
if (process.argv.length !== 2) throw new Error("Only --list is supported; use the owning runner for focused tests")
|
|
20
|
+
for (const command of commands) {
|
|
21
|
+
const result = spawnSync(command.runner, command.args, {stdio: "inherit"})
|
|
22
|
+
if (result.error) throw result.error
|
|
23
|
+
if (result.status !== 0) {
|
|
24
|
+
process.exitCode = result.status ?? 1
|
|
25
|
+
break
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {randomBytes, scrypt as scryptCallback, timingSafeEqual} from "node:crypto"
|
|
2
|
+
import {promisify} from "node:util"
|
|
3
|
+
|
|
4
|
+
const scrypt = promisify(scryptCallback)
|
|
5
|
+
const KEY_LENGTH = 64
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} password - password value.
|
|
9
|
+
* @returns {Promise<string>} - Result value.
|
|
10
|
+
*/
|
|
11
|
+
export async function createPasswordHash(password) {
|
|
12
|
+
const salt = randomBytes(16).toString("base64url")
|
|
13
|
+
const key = await scrypt(password, salt, KEY_LENGTH)
|
|
14
|
+
return `scrypt$${salt}$${Buffer.from(key).toString("base64url")}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} password - password value.
|
|
19
|
+
* @param {string} storedHash - storedHash value.
|
|
20
|
+
* @returns {Promise<boolean>} - Result value.
|
|
21
|
+
*/
|
|
22
|
+
export async function verifyPassword(password, storedHash) {
|
|
23
|
+
const parts = storedHash.split("$")
|
|
24
|
+
if (parts.length !== 3 || parts[0] !== "scrypt") {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const [, salt, expectedKey] = parts
|
|
29
|
+
if (!salt || !expectedKey) {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const actual = await scrypt(password, salt, KEY_LENGTH)
|
|
34
|
+
const actualBuffer = Buffer.from(actual)
|
|
35
|
+
const expectedBuffer = Buffer.from(expectedKey, "base64url")
|
|
36
|
+
|
|
37
|
+
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
|
|
38
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {createHash} from "node:crypto"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {object} StaticTokenPrincipal
|
|
5
|
+
* @property {string} userId - Stable user identifier derived from the username.
|
|
6
|
+
* @property {string} username - Authenticated username.
|
|
7
|
+
* @typedef {object} StaticTokenInput
|
|
8
|
+
* @property {string} username - Username associated with the token.
|
|
9
|
+
* @property {string} token - Opaque bearer token.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class StaticTokenStore {
|
|
13
|
+
/** @type {Map<string, StaticTokenPrincipal>} */
|
|
14
|
+
#principalsByToken = new Map()
|
|
15
|
+
|
|
16
|
+
/** @param {StaticTokenInput[]} tokens - Static bearer tokens. */
|
|
17
|
+
constructor(tokens) {
|
|
18
|
+
for (const token of tokens) {
|
|
19
|
+
this.#principalsByToken.set(token.token, {
|
|
20
|
+
userId: createStableUserId(token.username),
|
|
21
|
+
username: token.username
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} value - Bearer token value.
|
|
28
|
+
* @returns {StaticTokenPrincipal | undefined} Authenticated principal.
|
|
29
|
+
*/
|
|
30
|
+
authenticate(value) {
|
|
31
|
+
return this.#principalsByToken.get(value)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} username - Username to hash.
|
|
37
|
+
* @returns {string} Stable user identifier.
|
|
38
|
+
*/
|
|
39
|
+
function createStableUserId(username) {
|
|
40
|
+
return `static:${createHash("sha256").update(username).digest("base64url")}`
|
|
41
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {randomBytes} from "node:crypto"
|
|
2
|
+
|
|
3
|
+
import {createPasswordHash, verifyPassword} from "./passwords.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} User
|
|
7
|
+
* @property {string} id - id value.
|
|
8
|
+
* @property {string} username - username value.
|
|
9
|
+
* @property {string} passwordHash - passwordHash value.
|
|
10
|
+
* @typedef {object} CreateUserInput
|
|
11
|
+
* @property {string} username - username value.
|
|
12
|
+
* @property {string} password - password value.
|
|
13
|
+
* @typedef {object} LoginInput
|
|
14
|
+
* @property {string} username - username value.
|
|
15
|
+
* @property {string} password - password value.
|
|
16
|
+
* @typedef {object} TokenPrincipal
|
|
17
|
+
* @property {string} userId - userId value.
|
|
18
|
+
* @property {string} username - username value.
|
|
19
|
+
* @typedef {TokenPrincipal & {value: string, expiresAt: number}} AuthToken
|
|
20
|
+
* @typedef {object} TokenStoreOptions
|
|
21
|
+
* @property {() => number} [now] - now value.
|
|
22
|
+
* @property {number} [tokenTtlMs] - tokenTtlMs value.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export class InMemoryUserStore {
|
|
26
|
+
/** @type {Map<string, User>} */
|
|
27
|
+
#usersByUsername = new Map()
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {CreateUserInput} input - input value.
|
|
31
|
+
* @returns {Promise<User>} - Result value.
|
|
32
|
+
*/
|
|
33
|
+
async createUser(input) {
|
|
34
|
+
if (this.#usersByUsername.has(input.username)) {
|
|
35
|
+
throw new Error(`User already exists: ${input.username}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const user = {
|
|
39
|
+
id: generateOpaqueToken(),
|
|
40
|
+
username: input.username,
|
|
41
|
+
passwordHash: await createPasswordHash(input.password)
|
|
42
|
+
}
|
|
43
|
+
this.#usersByUsername.set(input.username, user)
|
|
44
|
+
return user
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} username - username value.
|
|
49
|
+
* @returns {User | undefined} - Result value.
|
|
50
|
+
*/
|
|
51
|
+
findByUsername(username) {
|
|
52
|
+
return this.#usersByUsername.get(username)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class TokenStore {
|
|
57
|
+
/** @type {Map<string, AuthToken>} */
|
|
58
|
+
#tokens = new Map()
|
|
59
|
+
|
|
60
|
+
/** @type {() => number} */
|
|
61
|
+
#now
|
|
62
|
+
|
|
63
|
+
/** @type {number} */
|
|
64
|
+
#tokenTtlMs
|
|
65
|
+
|
|
66
|
+
/** @param {TokenStoreOptions} [options] - Token store options. */
|
|
67
|
+
constructor(options = {}) {
|
|
68
|
+
this.#now = options.now ?? Date.now
|
|
69
|
+
this.#tokenTtlMs = options.tokenTtlMs ?? 60 * 60 * 1000
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {TokenPrincipal} principal - principal value.
|
|
74
|
+
* @returns {AuthToken} - Result value.
|
|
75
|
+
*/
|
|
76
|
+
issue(principal) {
|
|
77
|
+
const token = {
|
|
78
|
+
...principal,
|
|
79
|
+
value: generateOpaqueToken(),
|
|
80
|
+
expiresAt: this.#now() + this.#tokenTtlMs
|
|
81
|
+
}
|
|
82
|
+
this.#tokens.set(token.value, token)
|
|
83
|
+
return token
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} value - value value.
|
|
88
|
+
* @returns {TokenPrincipal | undefined} - Result value.
|
|
89
|
+
*/
|
|
90
|
+
authenticate(value) {
|
|
91
|
+
const token = this.#tokens.get(value)
|
|
92
|
+
if (!token) {
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (token.expiresAt <= this.#now()) {
|
|
97
|
+
this.#tokens.delete(value)
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
userId: token.userId,
|
|
103
|
+
username: token.username
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {string} value - value value.
|
|
109
|
+
* @returns {boolean} - Result value.
|
|
110
|
+
*/
|
|
111
|
+
revoke(value) {
|
|
112
|
+
return this.#tokens.delete(value)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {InMemoryUserStore} users - users value.
|
|
118
|
+
* @param {TokenStore} tokens - tokens value.
|
|
119
|
+
* @param {LoginInput} input - input value.
|
|
120
|
+
* @returns {Promise<AuthToken>} - Result value.
|
|
121
|
+
*/
|
|
122
|
+
export async function login(users, tokens, input) {
|
|
123
|
+
const user = users.findByUsername(input.username)
|
|
124
|
+
if (!user) {
|
|
125
|
+
throw new Error("Invalid username or password")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const passwordValid = await verifyPassword(input.password, user.passwordHash)
|
|
129
|
+
if (!passwordValid) {
|
|
130
|
+
throw new Error("Invalid username or password")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return tokens.issue({userId: user.id, username: user.username})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @returns {string} - Opaque token. */
|
|
137
|
+
function generateOpaqueToken() {
|
|
138
|
+
return randomBytes(32).toString("base64url")
|
|
139
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {SessionSpool as SessionSpoolStorage} from "../spool/spool-file.js"
|
|
2
|
+
import {connectSocketduct} from "./socketduct-socket.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {import('./socketduct-socket.js').ConnectSocketductOptions} ConnectSocketductOptions
|
|
6
|
+
* @typedef {import('./socketduct-socket.js').SocketductSocket} SocketductSocket
|
|
7
|
+
* @typedef {import('./socketduct-socket.js').SocketductTlsOptions} SocketductTlsOptions
|
|
8
|
+
* @typedef {{host?: string, port?: number, target?: {host: string, port: number}, sessionName?: string, spoolDirectory?: string, tls?: SocketductTlsOptions, logger?: import('../logging/structured-logger.js').DiagnosticLogger}} SocketductConnectionOptions
|
|
9
|
+
* @typedef {object} SocketductConnectionFactoryOptions
|
|
10
|
+
* @property {{host: string, port: number, token: string}} relay - Relay connection options.
|
|
11
|
+
* @property {string} spoolDirectory - Default spool directory.
|
|
12
|
+
* @property {{initialDelayMs?: number}} [reconnect] - Reconnect options.
|
|
13
|
+
* @property {string} [sessionNamePrefix] - Session name prefix for generated sessions.
|
|
14
|
+
* @property {string} [sessionName] - Default session name.
|
|
15
|
+
* @property {SocketductTlsOptions} [tls] - TLS client options.
|
|
16
|
+
* @property {number} [maxBytes] - Maximum total spool bytes per session.
|
|
17
|
+
* @property {number} [maxAgeMs] - Maximum age of a spool session before operations are rejected.
|
|
18
|
+
* @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {SocketductConnectionFactoryOptions} baseOptions - Shared Socketduct options.
|
|
23
|
+
* @returns {(options: SocketductConnectionOptions, listener?: () => void) => SocketductSocket} - Socket factory.
|
|
24
|
+
*/
|
|
25
|
+
export function createSocketductConnectionFactory(baseOptions) {
|
|
26
|
+
let connectionId = 0
|
|
27
|
+
return (options, listener) => {
|
|
28
|
+
connectionId += 1
|
|
29
|
+
const target = options.target ?? normalizeTarget(options)
|
|
30
|
+
const sessionName = options.sessionName
|
|
31
|
+
?? baseOptions.sessionName
|
|
32
|
+
?? `${baseOptions.sessionNamePrefix ?? "socketduct"}-${connectionId}`
|
|
33
|
+
|
|
34
|
+
/** @type {ConnectSocketductOptions} */
|
|
35
|
+
const socketductOptions = {
|
|
36
|
+
relay: baseOptions.relay,
|
|
37
|
+
sessionName,
|
|
38
|
+
target,
|
|
39
|
+
spoolDirectory: options.spoolDirectory ?? baseOptions.spoolDirectory
|
|
40
|
+
}
|
|
41
|
+
if (baseOptions.reconnect !== undefined) {
|
|
42
|
+
socketductOptions.reconnect = baseOptions.reconnect
|
|
43
|
+
}
|
|
44
|
+
if (options.tls !== undefined) {
|
|
45
|
+
socketductOptions.tls = options.tls
|
|
46
|
+
} else if (baseOptions.tls !== undefined) {
|
|
47
|
+
socketductOptions.tls = baseOptions.tls
|
|
48
|
+
}
|
|
49
|
+
if (baseOptions.maxBytes !== undefined) {
|
|
50
|
+
socketductOptions.maxBytes = baseOptions.maxBytes
|
|
51
|
+
}
|
|
52
|
+
if (baseOptions.maxAgeMs !== undefined) {
|
|
53
|
+
socketductOptions.maxAgeMs = baseOptions.maxAgeMs
|
|
54
|
+
}
|
|
55
|
+
if (options.logger !== undefined) {
|
|
56
|
+
socketductOptions.logger = options.logger
|
|
57
|
+
} else if (baseOptions.logger !== undefined) {
|
|
58
|
+
socketductOptions.logger = baseOptions.logger
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return connectSocketduct(socketductOptions, listener)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {SocketductConnectionOptions} options - Per-connection options.
|
|
67
|
+
* @returns {{host: string, port: number}} - Normalized target.
|
|
68
|
+
*/
|
|
69
|
+
function normalizeTarget(options) {
|
|
70
|
+
if (options.host === undefined || options.port === undefined) {
|
|
71
|
+
throw new Error("Socketduct connection requires target or host and port")
|
|
72
|
+
}
|
|
73
|
+
return {host: options.host, port: options.port}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export {SessionSpoolStorage}
|