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,238 @@
|
|
|
1
|
+
import {expect} from "@velocious/testing"
|
|
2
|
+
import {spawnSync} from "node:child_process"
|
|
3
|
+
import {createHash} from "node:crypto"
|
|
4
|
+
import {existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, statfsSync, unlinkSync, writeFileSync} from "node:fs"
|
|
5
|
+
|
|
6
|
+
import {createJsonlFileLogger, createStructuredLogger} from "../../../src/logging/structured-logger.js"
|
|
7
|
+
|
|
8
|
+
const root = "/fault-space"
|
|
9
|
+
const directory = `${root}/logs`
|
|
10
|
+
const active = `${directory}/reverse-gateway-1.jsonl`
|
|
11
|
+
const mode = process.argv[2] ?? "matrix"
|
|
12
|
+
expect(["matrix", "tear"].includes(mode)).toBeTruthy()
|
|
13
|
+
expect(process.getuid()).toBe(1000)
|
|
14
|
+
expect(process.getgid()).toBe(1000)
|
|
15
|
+
expect(realpathSync(root)).toBe(root)
|
|
16
|
+
const mount = readFileSync("/proc/self/mountinfo", "utf8").trim().split("\n").find((line) => line.split(" ")[4] === root)
|
|
17
|
+
expect(mount).toBeTruthy()
|
|
18
|
+
expect(mount.split(" ")[3]).toBe("/")
|
|
19
|
+
expect(mount.split(" - ")[1].split(" ")[0]).toBe("tmpfs")
|
|
20
|
+
const filesystem = statfsSync(root)
|
|
21
|
+
expect(filesystem.type).toBe(0x01021994)
|
|
22
|
+
expect(filesystem.blocks * filesystem.bsize).toBe(65536)
|
|
23
|
+
expect(readdirSync(root)).toEqual([])
|
|
24
|
+
expect(statSync(root).uid).toBe(1000)
|
|
25
|
+
expect(statSync(root).mode & 0o777).toBe(0o700)
|
|
26
|
+
process.umask(0o077)
|
|
27
|
+
const loggerHash = createHash("sha256").update(readFileSync(new URL("../../../src/logging/structured-logger.js", import.meta.url))).digest("hex")
|
|
28
|
+
console.log(JSON.stringify({phase: "matrix-identity", node: process.version, uid: process.getuid(), gid: process.getgid(), loggerHash}))
|
|
29
|
+
const identity = {role: "reverse-gateway", processId: 1, now: () => new Date("2026-09-08T06:00:00.000Z")}
|
|
30
|
+
const fields = {generation: 1000, gatewayId: "g".repeat(64), physicalConnects: 1000, reconnect: true}
|
|
31
|
+
let largeLine
|
|
32
|
+
createStructuredLogger({...identity, writeLine: (line) => { largeLine = line }}).log("transport.connected", fields)
|
|
33
|
+
expect(largeLine).toBeTruthy()
|
|
34
|
+
const largeRecord = Buffer.from(largeLine)
|
|
35
|
+
expect(largeRecord.length <= 1024).toBeTruthy()
|
|
36
|
+
|
|
37
|
+
if (mode === "tear") {
|
|
38
|
+
const maxFiles = Number(process.argv[3])
|
|
39
|
+
expect([1, 2, 5].includes(maxFiles)).toBeTruthy()
|
|
40
|
+
await shortWrites(maxFiles, 1, true)
|
|
41
|
+
} else {
|
|
42
|
+
for (const maxFiles of [1, 2, 5]) {
|
|
43
|
+
await shortWrites(maxFiles, maxFiles === 1 ? 1 : 2, false)
|
|
44
|
+
resetCase()
|
|
45
|
+
restart(maxFiles)
|
|
46
|
+
resetCase()
|
|
47
|
+
await noByteWrites(maxFiles)
|
|
48
|
+
resetCase()
|
|
49
|
+
}
|
|
50
|
+
expect(readdirSync(root)).toEqual([])
|
|
51
|
+
console.log(JSON.stringify({phase: "matrix-correct-output-pass", cases: 9, node: process.version, uid: process.getuid(), gid: process.getgid(), loggerHash, fixtureSpaceEmpty: true}))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {number} maxFiles - Retention count.
|
|
56
|
+
* @param {number} cycles - Actual short appends to exercise.
|
|
57
|
+
* @param {boolean} tearOnly - Exit the child after fault preparation.
|
|
58
|
+
* @returns {Promise<void>} Resolves after bounded fault and recovery checks.
|
|
59
|
+
*/
|
|
60
|
+
async function shortWrites(maxFiles, cycles, tearOnly) {
|
|
61
|
+
const reports = []
|
|
62
|
+
const logger = createJsonlFileLogger({...identity, directory, maxFiles, maxFileBytes: 8192, stderr: {write: (message) => reports.push(message)}})
|
|
63
|
+
const knownTorn = []
|
|
64
|
+
for (let cycle = 0; cycle < cycles; cycle += 1) {
|
|
65
|
+
let positioned = false
|
|
66
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
67
|
+
logger.log("process.started", {runtimeMode: "reverse-gateway"})
|
|
68
|
+
const size = statSync(active).size
|
|
69
|
+
if (size % filesystem.bsize > 0 && filesystem.bsize - size % filesystem.bsize < largeRecord.length) {
|
|
70
|
+
positioned = true
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
expect(positioned).toBeTruthy()
|
|
75
|
+
const before = readFileSync(active)
|
|
76
|
+
expect(before.at(-1)).toBe(10)
|
|
77
|
+
let torn
|
|
78
|
+
await withFullDisk(() => {
|
|
79
|
+
logger.log("transport.connected", fields)
|
|
80
|
+
torn = readFileSync(active)
|
|
81
|
+
expect(torn.length > before.length).toBeTruthy()
|
|
82
|
+
expect(torn.length < before.length + largeRecord.length).toBeTruthy()
|
|
83
|
+
expect(torn.subarray(0, before.length).equals(before)).toBeTruthy()
|
|
84
|
+
expect(torn.subarray(before.length).equals(largeRecord.subarray(0, torn.length - before.length))).toBeTruthy()
|
|
85
|
+
expect(torn.at(-1) === 10).toBe(false)
|
|
86
|
+
expect(torn.length % filesystem.bsize).toBe(0)
|
|
87
|
+
knownTorn.push(torn)
|
|
88
|
+
for (let generation = 0; !tearOnly && generation < 3; generation += 1) {
|
|
89
|
+
logger.log("transport.connected", {generation})
|
|
90
|
+
assertManagedFiles(knownTorn, maxFiles, 8192)
|
|
91
|
+
const evidence = maxFiles === 1 ? active : `${active}.1`
|
|
92
|
+
expect(readFileSync(evidence).equals(torn)).toBeTruthy()
|
|
93
|
+
if (maxFiles === 2 && cycle > 0) {
|
|
94
|
+
const records = readFileSync(active, "utf8").trimEnd().split("\n").map((line) => JSON.parse(line))
|
|
95
|
+
expect(records.map((record) => record.generation)).toEqual(Array.from({length: generation + 1}, (_, index) => index))
|
|
96
|
+
} else if (maxFiles > 1) {
|
|
97
|
+
expect(statSync(active).size).toBe(0)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
101
|
+
console.log(JSON.stringify({phase: "actual-enospc", case: tearOnly ? "short-before-process-exit" : "short-and-ordinary-calls", maxFiles, cycle, beforeBytes: before.length, attemptedBytes: largeRecord.length, partialBytes: torn.length - before.length, tornBytes: torn.length, filesystemError: "ENOSPC", retentionFreedBlock: maxFiles === 2 && cycle > 0, reports}))
|
|
102
|
+
})
|
|
103
|
+
expect(torn).toBeTruthy()
|
|
104
|
+
if (tearOnly) {
|
|
105
|
+
console.log(JSON.stringify({phase: "prepared-for-restart", writerPid: process.pid, tornHash: createHash("sha256").update(torn).digest("hex")}))
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
assertRecovery(logger, torn, maxFiles, maxFiles === 1 ? Buffer.alloc(0) : readFileSync(active))
|
|
109
|
+
assertManagedFiles(knownTorn, maxFiles, 8192)
|
|
110
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
111
|
+
}
|
|
112
|
+
console.log(JSON.stringify({phase: "case-pass", case: "same-instance", maxFiles, cycles}))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {number} maxFiles - Retention count. */
|
|
116
|
+
function restart(maxFiles) {
|
|
117
|
+
const child = spawnSync(process.execPath, [import.meta.filename, "tear", String(maxFiles)], {encoding: "utf8", timeout: 10_000})
|
|
118
|
+
if (child.error) throw child.error
|
|
119
|
+
process.stdout.write(child.stdout)
|
|
120
|
+
expect(child.status).toBe(0)
|
|
121
|
+
expect(child.signal).toBe(null)
|
|
122
|
+
expect(child.stderr).toBe("")
|
|
123
|
+
const receipt = JSON.parse(child.stdout.trimEnd().split("\n").at(-1))
|
|
124
|
+
expect(receipt.phase).toBe("prepared-for-restart")
|
|
125
|
+
const torn = readFileSync(active)
|
|
126
|
+
expect(createHash("sha256").update(torn).digest("hex")).toBe(receipt.tornHash)
|
|
127
|
+
const original = statSync(active)
|
|
128
|
+
const reports = []
|
|
129
|
+
const logger = createJsonlFileLogger({...identity, directory, maxFiles, maxFileBytes: 8192, stderr: {write: (message) => reports.push(message)}})
|
|
130
|
+
assertRecovery(logger, torn, maxFiles)
|
|
131
|
+
const evidence = maxFiles === 1 ? active : `${active}.1`
|
|
132
|
+
expect(statSync(evidence).ino).toBe(original.ino)
|
|
133
|
+
assertManagedFiles([torn], maxFiles, 8192)
|
|
134
|
+
expect(reports).toEqual(maxFiles === 1 ? ["Socketduct logger failure (LOGGER_IO_ERROR)\n"] : [])
|
|
135
|
+
console.log(JSON.stringify({phase: "case-pass", case: "process-restart", maxFiles, exitedWriterPid: receipt.writerPid, resumedWriterPid: process.pid}))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {number} maxFiles - Retention count.
|
|
140
|
+
* @returns {Promise<void>} Resolves after repeated no-byte failure checks.
|
|
141
|
+
*/
|
|
142
|
+
async function noByteWrites(maxFiles) {
|
|
143
|
+
mkdirSync(directory, {mode: 0o700})
|
|
144
|
+
const reports = []
|
|
145
|
+
const logger = createJsonlFileLogger({...identity, directory, maxFiles, maxFileBytes: 1024, stderr: {write: (message) => reports.push(message)}})
|
|
146
|
+
await withFullDisk(() => {
|
|
147
|
+
for (let generation = 0; generation < 3; generation += 1) {
|
|
148
|
+
logger.log("transport.connected", {generation})
|
|
149
|
+
expect(statSync(active).size).toBe(0)
|
|
150
|
+
}
|
|
151
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
152
|
+
console.log(JSON.stringify({phase: "actual-enospc", case: "empty-file-repeated-no-byte", maxFiles, maxFileBytes: 1024, filesystemError: "ENOSPC", reports}))
|
|
153
|
+
})
|
|
154
|
+
logger.log("transport.connected", {generation: 77})
|
|
155
|
+
logger.log("runtime.status", {physicalConnections: 1})
|
|
156
|
+
const lines = readFileSync(active, "utf8").trimEnd().split("\n")
|
|
157
|
+
expect(lines.map((line) => JSON.parse(line).event)).toEqual(["transport.connected", "runtime.status"])
|
|
158
|
+
assertManagedFiles([], maxFiles, 1024)
|
|
159
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
160
|
+
console.log(JSON.stringify({phase: "case-pass", case: "no-byte", maxFiles}))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {() => void} exercise - Faulted operations.
|
|
165
|
+
* @returns {Promise<void>} Resolves after restoring the fixture filler space.
|
|
166
|
+
*/
|
|
167
|
+
async function withFullDisk(exercise) {
|
|
168
|
+
const filler = `${root}/filler`
|
|
169
|
+
const probe = `${root}/no-space-probe`
|
|
170
|
+
try {
|
|
171
|
+
const freeBytes = statfsSync(root).bavail * filesystem.bsize
|
|
172
|
+
expect(freeBytes > 0 && freeBytes <= 65536).toBeTruthy()
|
|
173
|
+
writeFileSync(filler, Buffer.alloc(freeBytes), {flag: "wx", mode: 0o600})
|
|
174
|
+
expect(statfsSync(root).bavail).toBe(0)
|
|
175
|
+
await expect(Promise.resolve().then(() => writeFileSync(probe, Buffer.alloc(filesystem.bsize), {flag: "wx", mode: 0o600}))).rejects.toMatchObject({code: "ENOSPC"})
|
|
176
|
+
await exercise()
|
|
177
|
+
} finally {
|
|
178
|
+
if (existsSync(filler)) unlinkSync(filler)
|
|
179
|
+
if (existsSync(probe)) unlinkSync(probe)
|
|
180
|
+
}
|
|
181
|
+
expect(statfsSync(root).bavail > 0).toBeTruthy()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {import('../../../src/logging/structured-logger.js').DiagnosticLogger} logger - Resumed logger.
|
|
186
|
+
* @param {Buffer} torn - Exact damaged bytes.
|
|
187
|
+
* @param {number} maxFiles - Retention count.
|
|
188
|
+
* @param {Buffer} [prefix] - Complete records already accepted using a freed retention block.
|
|
189
|
+
*/
|
|
190
|
+
function assertRecovery(logger, torn, maxFiles, prefix = Buffer.alloc(0)) {
|
|
191
|
+
logger.log("transport.connected", {generation: 77})
|
|
192
|
+
logger.log("runtime.status", {physicalConnections: 1})
|
|
193
|
+
const evidence = maxFiles === 1 ? active : `${active}.1`
|
|
194
|
+
expect(readFileSync(evidence).equals(torn)).toBeTruthy()
|
|
195
|
+
if (maxFiles === 1) return
|
|
196
|
+
const recovered = readFileSync(active)
|
|
197
|
+
expect(recovered.subarray(0, prefix.length).equals(prefix)).toBeTruthy()
|
|
198
|
+
const contents = recovered.subarray(prefix.length).toString("utf8")
|
|
199
|
+
expect(contents.endsWith("\n")).toBeTruthy()
|
|
200
|
+
expect(contents.trimEnd().split("\n").map((line) => JSON.parse(line))).toEqual([
|
|
201
|
+
{timestamp: identity.now().toISOString(), event: "transport.connected", processId: 1, role: identity.role, generation: 77},
|
|
202
|
+
{timestamp: identity.now().toISOString(), event: "runtime.status", processId: 1, role: identity.role, physicalConnections: 1}
|
|
203
|
+
])
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {Buffer[]} knownTorn - Explicitly identified incident evidence.
|
|
208
|
+
* @param {number} maxFiles - Retention count.
|
|
209
|
+
* @param {number} maxFileBytes - Per-file limit.
|
|
210
|
+
*/
|
|
211
|
+
function assertManagedFiles(knownTorn, maxFiles, maxFileBytes) {
|
|
212
|
+
const files = readdirSync(directory)
|
|
213
|
+
expect(files.length <= maxFiles).toBeTruthy()
|
|
214
|
+
expect(statSync(directory).mode & 0o777).toBe(0o700)
|
|
215
|
+
for (const file of files) {
|
|
216
|
+
expect(file).toMatch(/^reverse-gateway-1\.jsonl(?:\.[1-9][0-9]*)?$/)
|
|
217
|
+
const stats = statSync(`${directory}/${file}`)
|
|
218
|
+
expect(stats.isFile()).toBeTruthy()
|
|
219
|
+
expect(stats.size <= maxFileBytes).toBeTruthy()
|
|
220
|
+
expect(stats.mode & 0o777).toBe(0o600)
|
|
221
|
+
const contents = readFileSync(`${directory}/${file}`)
|
|
222
|
+
if (knownTorn.some((torn) => torn.equals(contents))) continue
|
|
223
|
+
if (contents.length === 0) continue
|
|
224
|
+
expect(contents.at(-1)).toBe(10)
|
|
225
|
+
for (const line of contents.toString("utf8").trimEnd().split("\n")) {
|
|
226
|
+
expect(Buffer.byteLength(`${line}\n`) <= 1024).toBeTruthy()
|
|
227
|
+
JSON.parse(line)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Clears only contents owned by the completed disposable case. */
|
|
233
|
+
function resetCase() {
|
|
234
|
+
expect(readdirSync(root)).toEqual(["logs"])
|
|
235
|
+
for (const file of readdirSync(directory)) expect(file).toMatch(/^reverse-gateway-1\.jsonl(?:\.[1-9][0-9]*)?$/)
|
|
236
|
+
rmSync(directory, {recursive: true})
|
|
237
|
+
expect(readdirSync(root)).toEqual([])
|
|
238
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {expect} from "@velocious/testing"
|
|
2
|
+
import {createHash} from "node:crypto"
|
|
3
|
+
import {existsSync, readFileSync, readdirSync, realpathSync, statSync, statfsSync, unlinkSync, writeFileSync} from "node:fs"
|
|
4
|
+
|
|
5
|
+
import {createJsonlFileLogger, createStructuredLogger} from "../../../src/logging/structured-logger.js"
|
|
6
|
+
|
|
7
|
+
const root = "/fault-space"
|
|
8
|
+
expect(process.getuid()).toBe(1000)
|
|
9
|
+
expect(process.getgid()).toBe(1000)
|
|
10
|
+
expect(realpathSync(root)).toBe(root)
|
|
11
|
+
const mounts = readFileSync("/proc/self/mountinfo", "utf8").trim().split("\n")
|
|
12
|
+
const mount = mounts.find((line) => line.split(" ")[4] === root)
|
|
13
|
+
expect(mount).toBeTruthy()
|
|
14
|
+
expect(mount.split(" - ")[1].split(" ")[0]).toBe("tmpfs")
|
|
15
|
+
expect(mount.split(" ")[3]).toBe("/")
|
|
16
|
+
const filesystem = statfsSync(root)
|
|
17
|
+
expect(filesystem.type).toBe(0x01021994)
|
|
18
|
+
expect(filesystem.blocks * filesystem.bsize).toBe(64 * 1024)
|
|
19
|
+
expect(readdirSync(root)).toEqual([])
|
|
20
|
+
expect(statSync(root).uid).toBe(1000)
|
|
21
|
+
expect(statSync(root).mode & 0o777).toBe(0o700)
|
|
22
|
+
process.umask(0o077)
|
|
23
|
+
|
|
24
|
+
const directory = `${root}/logs`
|
|
25
|
+
const active = `${directory}/reverse-gateway-1.jsonl`
|
|
26
|
+
const filler = `${root}/filler`
|
|
27
|
+
const probe = `${root}/no-space-probe`
|
|
28
|
+
const options = {directory, role: "reverse-gateway", processId: 1, now: () => new Date("2026-09-08T06:00:00.000Z")}
|
|
29
|
+
const reports = []
|
|
30
|
+
const logger = createJsonlFileLogger({...options, stderr: {write: (message) => reports.push(message)}})
|
|
31
|
+
const fields = {
|
|
32
|
+
activeTargets: 10,
|
|
33
|
+
activeTunnels: 10,
|
|
34
|
+
attachedSessions: 10,
|
|
35
|
+
authenticatedConnections: 10,
|
|
36
|
+
bufferedBytes: 10,
|
|
37
|
+
bufferedTargetBytes: 10,
|
|
38
|
+
clientPhysicalConnections: 10,
|
|
39
|
+
connectionGeneration: 10,
|
|
40
|
+
connectionIdentities: 10,
|
|
41
|
+
controlQueuedBytes: 10,
|
|
42
|
+
dataQueuedBytes: 10,
|
|
43
|
+
detachedSessions: 10,
|
|
44
|
+
gatewayPhysicalConnections: 10,
|
|
45
|
+
gatewayRegistrations: 10,
|
|
46
|
+
healthyGateways: 10,
|
|
47
|
+
openFailures: 10,
|
|
48
|
+
pendingOpens: 10,
|
|
49
|
+
physicalAccepts: 10,
|
|
50
|
+
physicalConnections: 10,
|
|
51
|
+
physicalConnects: 10,
|
|
52
|
+
rawDataBytes: 10,
|
|
53
|
+
registeredGateways: 10,
|
|
54
|
+
rejectedOpens: 10,
|
|
55
|
+
rejectedRegistrations: 10,
|
|
56
|
+
retainedClientIdentities: 10,
|
|
57
|
+
targetOpens: 10,
|
|
58
|
+
virtualStreams: 10,
|
|
59
|
+
wireDataBytes: 10
|
|
60
|
+
}
|
|
61
|
+
let largeLine
|
|
62
|
+
createStructuredLogger({...options, writeLine: (line) => { largeLine = line }}).log("runtime.status", fields)
|
|
63
|
+
expect(largeLine).toBeTruthy()
|
|
64
|
+
const largeRecord = Buffer.from(largeLine)
|
|
65
|
+
expect(largeRecord.length <= 1024).toBeTruthy()
|
|
66
|
+
const blockBytes = filesystem.bsize
|
|
67
|
+
let initialEvents = 0
|
|
68
|
+
for (; initialEvents < 100; initialEvents += 1) {
|
|
69
|
+
logger.log("process.started", {runtimeMode: "reverse-gateway"})
|
|
70
|
+
const size = statSync(active).size
|
|
71
|
+
if (size % blockBytes > 0 && blockBytes - size % blockBytes < largeRecord.length) {
|
|
72
|
+
initialEvents += 1
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
expect(initialEvents < 100).toBeTruthy()
|
|
77
|
+
const before = readFileSync(active)
|
|
78
|
+
expect(before.at(-1)).toBe(10)
|
|
79
|
+
for (const line of before.toString("utf8").trimEnd().split("\n")) JSON.parse(line)
|
|
80
|
+
expect(reports).toEqual([])
|
|
81
|
+
|
|
82
|
+
let torn
|
|
83
|
+
try {
|
|
84
|
+
const freeBytes = statfsSync(root).bavail * blockBytes
|
|
85
|
+
expect(freeBytes > 0 && freeBytes <= 64 * 1024).toBeTruthy()
|
|
86
|
+
writeFileSync(filler, Buffer.alloc(freeBytes), {flag: "wx", mode: 0o600})
|
|
87
|
+
expect(statfsSync(root).bavail).toBe(0)
|
|
88
|
+
await expect(Promise.resolve().then(() => writeFileSync(probe, Buffer.alloc(blockBytes), {flag: "wx", mode: 0o600}))).rejects.toMatchObject({code: "ENOSPC"})
|
|
89
|
+
await expect(() => logger.log("runtime.status", fields)).not.toThrow()
|
|
90
|
+
torn = readFileSync(active)
|
|
91
|
+
expect(torn.length > before.length).toBeTruthy()
|
|
92
|
+
expect(torn.length < before.length + largeRecord.length).toBeTruthy()
|
|
93
|
+
expect(torn.length % blockBytes).toBe(0)
|
|
94
|
+
expect(torn.subarray(0, before.length).equals(before)).toBeTruthy()
|
|
95
|
+
const partialRecord = torn.subarray(before.length)
|
|
96
|
+
expect(partialRecord.equals(largeRecord.subarray(0, partialRecord.length))).toBeTruthy()
|
|
97
|
+
expect(partialRecord.includes(10)).toBe(false)
|
|
98
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
99
|
+
console.log(JSON.stringify({
|
|
100
|
+
phase: "actual-enospc-short-append",
|
|
101
|
+
uid: process.getuid(),
|
|
102
|
+
gid: process.getgid(),
|
|
103
|
+
node: process.version,
|
|
104
|
+
loggerHash: createHash("sha256").update(readFileSync(new URL("../../../src/logging/structured-logger.js", import.meta.url))).digest("hex"),
|
|
105
|
+
filesystemError: "ENOSPC",
|
|
106
|
+
blockBytes,
|
|
107
|
+
initialEvents,
|
|
108
|
+
beforeBytes: before.length,
|
|
109
|
+
attemptedRecordBytes: largeRecord.length,
|
|
110
|
+
tornBytes: torn.length,
|
|
111
|
+
partialRecordBytes: partialRecord.length,
|
|
112
|
+
tornHash: createHash("sha256").update(torn).digest("hex")
|
|
113
|
+
}))
|
|
114
|
+
} finally {
|
|
115
|
+
if (existsSync(filler)) unlinkSync(filler)
|
|
116
|
+
if (existsSync(probe)) unlinkSync(probe)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
expect(statfsSync(root).bavail > 0).toBeTruthy()
|
|
120
|
+
expect(torn).toBeTruthy()
|
|
121
|
+
logger.log("transport.connected", {generation: 1, reconnect: true})
|
|
122
|
+
logger.log("runtime.status", {physicalConnections: 1})
|
|
123
|
+
const recovered = readFileSync(active)
|
|
124
|
+
const retainedPath = `${active}.1`
|
|
125
|
+
const preserved = existsSync(retainedPath) && readFileSync(retainedPath).equals(torn)
|
|
126
|
+
const lines = recovered.toString("utf8").trimEnd().split("\n")
|
|
127
|
+
const joinedRecords = lines.filter((line) => (line.match(/\{"timestamp":/g) ?? []).length > 1).length
|
|
128
|
+
console.log(JSON.stringify({phase: "recovery-observed", preserved, joinedRecords, activeBytes: recovered.length, reports}))
|
|
129
|
+
expect(preserved).toBe(true)
|
|
130
|
+
expect(joinedRecords).toBe(0)
|
|
131
|
+
expect(recovered.at(-1)).toBe(10)
|
|
132
|
+
expect(lines.map((line) => JSON.parse(line))).toEqual([
|
|
133
|
+
{timestamp: options.now().toISOString(), event: "transport.connected", processId: 1, role: options.role, generation: 1, reconnect: true},
|
|
134
|
+
{timestamp: options.now().toISOString(), event: "runtime.status", processId: 1, role: options.role, physicalConnections: 1}
|
|
135
|
+
])
|
|
136
|
+
for (const line of lines) expect(Buffer.byteLength(`${line}\n`) <= 1024).toBeTruthy()
|
|
137
|
+
expect(readdirSync(directory).sort()).toEqual(["reverse-gateway-1.jsonl", "reverse-gateway-1.jsonl.1"])
|
|
138
|
+
expect(statSync(directory).mode & 0o777).toBe(0o700)
|
|
139
|
+
for (const path of [active, retainedPath]) {
|
|
140
|
+
expect(statSync(path).mode & 0o777).toBe(0o600)
|
|
141
|
+
expect(statSync(path).size <= 10 * 1024 * 1024).toBeTruthy()
|
|
142
|
+
}
|
|
143
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
144
|
+
expect(existsSync(filler)).toBe(false)
|
|
145
|
+
expect(existsSync(probe)).toBe(false)
|
|
146
|
+
console.log(JSON.stringify({phase: "correct-output-pass", preserved: true, newRecords: lines.length, joinedRecords: 0, fillerRemoved: true}))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {expect} from "@velocious/testing"
|
|
2
|
+
|
|
3
|
+
import {createJsonlFileLogger} from "../../../src/logging/structured-logger.js"
|
|
4
|
+
|
|
5
|
+
const [directory, maxFiles, maxFileBytes] = process.argv.slice(2)
|
|
6
|
+
expect(directory).toBeTruthy()
|
|
7
|
+
expect(maxFiles).toBeTruthy()
|
|
8
|
+
expect(maxFileBytes).toBeTruthy()
|
|
9
|
+
const reports = []
|
|
10
|
+
const logger = createJsonlFileLogger({
|
|
11
|
+
directory,
|
|
12
|
+
role: "reverse-gateway",
|
|
13
|
+
processId: 1,
|
|
14
|
+
maxFiles: Number(maxFiles),
|
|
15
|
+
maxFileBytes: Number(maxFileBytes),
|
|
16
|
+
stderr: {write: (message) => reports.push(message)}
|
|
17
|
+
})
|
|
18
|
+
logger.log("process.started", {runtimeMode: "reverse-gateway"})
|
|
19
|
+
logger.log("runtime.status", {physicalConnections: 1})
|
|
20
|
+
console.log(JSON.stringify({processId: process.pid, reports}))
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {execFileSync} from "node:child_process"
|
|
2
|
+
import {mkdirSync, writeFileSync} from "node:fs"
|
|
3
|
+
import {join} from "node:path"
|
|
4
|
+
|
|
5
|
+
const EC_CURVE = "prime256v1"
|
|
6
|
+
const DAYS_VALID = 365
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} TlsTestCertificates
|
|
10
|
+
* @property {string} caCertFile - Self-signed CA certificate path.
|
|
11
|
+
* @property {string} caKeyFile - CA private key path.
|
|
12
|
+
* @property {string} serverCertFile - Server certificate path (signed by CA).
|
|
13
|
+
* @property {string} serverKeyFile - Server private key path.
|
|
14
|
+
* @property {string} clientCertFile - Client certificate path (signed by CA, for mTLS).
|
|
15
|
+
* @property {string} clientKeyFile - Client private key path.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} TlsTestCertificateOptions
|
|
19
|
+
* @property {string} [serverSubjectAltName] - Server certificate subjectAltName extension.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Test-only self-signed TLS certificate material. NEVER use these files for production secrets.
|
|
23
|
+
* They are generated ephemerally inside temp directories during test runs.
|
|
24
|
+
* @param {string} dir - Directory to write certificate files into.
|
|
25
|
+
* @param {TlsTestCertificateOptions} [options] - Certificate overrides.
|
|
26
|
+
* @returns {TlsTestCertificates} Paths to generated certificate material.
|
|
27
|
+
*/
|
|
28
|
+
export function generateTestCertificates(dir, options = {}) {
|
|
29
|
+
mkdirSync(dir, {recursive: true})
|
|
30
|
+
|
|
31
|
+
const caKeyFile = join(dir, "ca.key")
|
|
32
|
+
const caCertFile = join(dir, "ca.crt")
|
|
33
|
+
const serverKeyFile = join(dir, "server.key")
|
|
34
|
+
const serverCertFile = join(dir, "server.crt")
|
|
35
|
+
const clientKeyFile = join(dir, "client.key")
|
|
36
|
+
const clientCertFile = join(dir, "client.crt")
|
|
37
|
+
|
|
38
|
+
execFileSync("openssl", [
|
|
39
|
+
"ecparam", "-genkey", "-name", EC_CURVE, "-out", caKeyFile
|
|
40
|
+
], {stdio: "ignore"})
|
|
41
|
+
|
|
42
|
+
execFileSync("openssl", [
|
|
43
|
+
"req", "-x509", "-new", "-key", caKeyFile,
|
|
44
|
+
"-out", caCertFile,
|
|
45
|
+
"-days", String(DAYS_VALID),
|
|
46
|
+
"-subj", "/CN=Socketduct Test CA/O=Socketduct Tests/OU=Test Only",
|
|
47
|
+
"-addext", "basicConstraints=critical,CA:TRUE,pathlen:0",
|
|
48
|
+
"-addext", "keyUsage=critical,keyCertSign,cRLSign"
|
|
49
|
+
], {stdio: "ignore"})
|
|
50
|
+
|
|
51
|
+
const sanExtFile = join(dir, "san.ext")
|
|
52
|
+
writeFileSync(sanExtFile, `subjectAltName = ${options.serverSubjectAltName ?? "DNS:localhost, IP:127.0.0.1"}\n`, "utf8")
|
|
53
|
+
|
|
54
|
+
signCertificate(serverKeyFile, serverCertFile, "socketduct-test-server", caKeyFile, caCertFile, sanExtFile)
|
|
55
|
+
signCertificate(clientKeyFile, clientCertFile, "socketduct-test-client", caKeyFile, caCertFile)
|
|
56
|
+
|
|
57
|
+
return {caCertFile, caKeyFile, serverCertFile, serverKeyFile, clientCertFile, clientKeyFile}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Generates an additional server certificate signed by an existing test CA.
|
|
62
|
+
* @param {string} dir - Directory to write certificate files into.
|
|
63
|
+
* @param {{caCertFile: string, caKeyFile: string, subjectAltName: string, commonName?: string}} options - Certificate options.
|
|
64
|
+
* @returns {{serverCertFile: string, serverKeyFile: string}} Generated server certificate paths.
|
|
65
|
+
*/
|
|
66
|
+
export function generateTestServerCertificate(dir, options) {
|
|
67
|
+
mkdirSync(dir, {recursive: true})
|
|
68
|
+
const serverKeyFile = join(dir, "server.key")
|
|
69
|
+
const serverCertFile = join(dir, "server.crt")
|
|
70
|
+
const sanExtFile = join(dir, "san.ext")
|
|
71
|
+
writeFileSync(sanExtFile, `subjectAltName = ${options.subjectAltName}\n`, "utf8")
|
|
72
|
+
signCertificate(
|
|
73
|
+
serverKeyFile,
|
|
74
|
+
serverCertFile,
|
|
75
|
+
options.commonName ?? "socketduct-test-server",
|
|
76
|
+
options.caKeyFile,
|
|
77
|
+
options.caCertFile,
|
|
78
|
+
sanExtFile
|
|
79
|
+
)
|
|
80
|
+
return {serverCertFile, serverKeyFile}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} keyFile - Output key file path.
|
|
85
|
+
* @param {string} certFile - Output certificate file path.
|
|
86
|
+
* @param {string} commonName - Certificate CN.
|
|
87
|
+
* @param {string} caKeyFile - CA private key path.
|
|
88
|
+
* @param {string} caCertFile - CA certificate path.
|
|
89
|
+
* @param {string} [extFile] - Optional extensions file for SAN.
|
|
90
|
+
*/
|
|
91
|
+
function signCertificate(keyFile, certFile, commonName, caKeyFile, caCertFile, extFile) {
|
|
92
|
+
execFileSync("openssl", [
|
|
93
|
+
"ecparam", "-genkey", "-name", EC_CURVE, "-out", keyFile
|
|
94
|
+
], {stdio: "ignore"})
|
|
95
|
+
|
|
96
|
+
const csrFile = certFile.replace(/\.crt$/, ".csr")
|
|
97
|
+
execFileSync("openssl", [
|
|
98
|
+
"req", "-new", "-key", keyFile,
|
|
99
|
+
"-subj", `/CN=${commonName}/O=Socketduct Tests/OU=Test Only`,
|
|
100
|
+
"-out", csrFile
|
|
101
|
+
], {stdio: "ignore"})
|
|
102
|
+
|
|
103
|
+
/** @type {string[]} */
|
|
104
|
+
const signArgs = [
|
|
105
|
+
"x509", "-req", "-in", csrFile,
|
|
106
|
+
"-CA", caCertFile, "-CAkey", caKeyFile,
|
|
107
|
+
"-CAcreateserial",
|
|
108
|
+
"-out", certFile,
|
|
109
|
+
"-days", String(DAYS_VALID)
|
|
110
|
+
]
|
|
111
|
+
if (extFile !== undefined) {
|
|
112
|
+
signArgs.push("-extfile", extFile)
|
|
113
|
+
}
|
|
114
|
+
execFileSync("openssl", signArgs, {stdio: "ignore"})
|
|
115
|
+
}
|