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.
Files changed (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. 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
+ }