@andrewheberle/serverless-ssh-ca-testing 0.0.0-stage → 0.20.1
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/README.md +84 -2
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +50 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +45 -0
- package/dist/config.js.map +1 -0
- package/dist/format.d.ts +27 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +31 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +68 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +54 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +88 -0
- package/dist/log.js.map +1 -0
- package/dist/node.d.ts +17 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +98 -0
- package/dist/node.js.map +1 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -0
- package/dist/worker.d.ts +8 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +25 -0
- package/dist/worker.js.map +1 -0
- package/dist/workerd.d.ts +24 -0
- package/dist/workerd.d.ts.map +1 -0
- package/dist/workerd.js +91 -0
- package/dist/workerd.js.map +1 -0
- package/package.json +72 -5
- package/src/cli.ts +57 -0
- package/src/config.ts +59 -0
- package/src/format.ts +33 -0
- package/src/index.ts +74 -0
- package/src/log.ts +98 -0
- package/src/node.ts +112 -0
- package/src/server.ts +9 -0
- package/src/worker.ts +30 -0
- package/src/workerd.ts +111 -0
package/src/log.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs"
|
|
2
|
+
import z from "zod"
|
|
3
|
+
import { formatRequest, formatValue } from "./format.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Messages the CA logs at error level when it has failed rather than rejected
|
|
7
|
+
* a request, see {@link isFault}.
|
|
8
|
+
*/
|
|
9
|
+
export const FAULT_MESSAGES: readonly string[] = ["unhandled error", "unexpected error from router"]
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The message of the line written by {@link LogFile.write} after a CA log line
|
|
13
|
+
* that indicates a fault.
|
|
14
|
+
*/
|
|
15
|
+
export const FAULT_MESSAGE = "harness fault"
|
|
16
|
+
|
|
17
|
+
// the fields of a CA log line used to detect faults
|
|
18
|
+
const LogLineSchema = z.object({
|
|
19
|
+
level: z.string(),
|
|
20
|
+
message: z.string(),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Report whether a line logged by the CA indicates a fault, such as a database
|
|
25
|
+
* error, rather than an expected rejection of a request. Some faults are only
|
|
26
|
+
* logged, as the CA still issues a certificate when it cannot record it.
|
|
27
|
+
*
|
|
28
|
+
* @param line A line of output from the CA
|
|
29
|
+
* @returns true if the line is an error logged by the CA for a fault
|
|
30
|
+
*/
|
|
31
|
+
export const isFault = (line: string): boolean => {
|
|
32
|
+
let json: unknown
|
|
33
|
+
try {
|
|
34
|
+
json = JSON.parse(line)
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const result = LogLineSchema.safeParse(json)
|
|
40
|
+
if (!result.success || result.data.level.toLowerCase() !== "error") {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return FAULT_MESSAGES.includes(result.data.message) || result.data.message.includes("database")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The log file of a test server, written as JSON lines.
|
|
49
|
+
*
|
|
50
|
+
* Lines from the CA are written as they are logged. The harness adds the
|
|
51
|
+
* following lines, whose messages all start with "harness":
|
|
52
|
+
*
|
|
53
|
+
* - `{"level":"info","message":"harness request",...}` for each request, with
|
|
54
|
+
* `method`, `url`, `status` and, when `status` is 400 or more, the response
|
|
55
|
+
* `body`.
|
|
56
|
+
* - `{"level":"error","message":"harness ...",...}` for a fault in the CA or
|
|
57
|
+
* harness. A CA log line that indicates a fault (see {@link isFault}) is
|
|
58
|
+
* followed by a line with the message {@link FAULT_MESSAGE}.
|
|
59
|
+
*/
|
|
60
|
+
export class LogFile {
|
|
61
|
+
readonly path: string
|
|
62
|
+
|
|
63
|
+
constructor(path: string) {
|
|
64
|
+
this.path = path
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Append a line of output from the CA, followed by a fault line if it
|
|
69
|
+
* indicates a fault.
|
|
70
|
+
*/
|
|
71
|
+
write(line: string): void {
|
|
72
|
+
this.append(line)
|
|
73
|
+
if (isFault(line)) {
|
|
74
|
+
this.append(formatValue({ level: "error", message: FAULT_MESSAGE, line }))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Append a request handled by the CA.
|
|
80
|
+
*/
|
|
81
|
+
request(method: string, url: string, status: number, body: string): void {
|
|
82
|
+
this.append(formatRequest(method, url, status, body))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Append a fault in the harness.
|
|
87
|
+
*
|
|
88
|
+
* @param message A description of the fault, which is prefixed with "harness"
|
|
89
|
+
* @param error The error that caused the fault
|
|
90
|
+
*/
|
|
91
|
+
fault(message: string, error: unknown): void {
|
|
92
|
+
this.append(formatValue({ level: "error", message: `harness ${message}`, error }))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private append(line: string): void {
|
|
96
|
+
appendFileSync(this.path, line + "\n")
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/node.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage } from "node:http"
|
|
2
|
+
import { DatabaseSync } from "node:sqlite"
|
|
3
|
+
import { createSshCa } from "@andrewheberle/serverless-ssh-ca"
|
|
4
|
+
import { bindingsFromEnv, fromNodeSqlite, secretFromString } from "@andrewheberle/serverless-ssh-ca/node"
|
|
5
|
+
import type { TestServerConfig } from "./config.js"
|
|
6
|
+
import { formatArgs } from "./format.js"
|
|
7
|
+
import type { LogFile } from "./log.js"
|
|
8
|
+
import type { TestServer } from "./server.js"
|
|
9
|
+
|
|
10
|
+
const consoleMethods = ["debug", "info", "log", "warn", "error"] as const
|
|
11
|
+
|
|
12
|
+
// convert a request received by the HTTP server into a Fetch API request
|
|
13
|
+
const toRequest = async (req: IncomingMessage): Promise<Request> => {
|
|
14
|
+
const chunks: Buffer[] = []
|
|
15
|
+
for await (const chunk of req) {
|
|
16
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const headers = new Headers()
|
|
20
|
+
for (const [name, values] of Object.entries(req.headersDistinct)) {
|
|
21
|
+
for (const value of values ?? []) {
|
|
22
|
+
headers.append(name, value)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const method = req.method ?? "GET"
|
|
27
|
+
|
|
28
|
+
return new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
|
|
29
|
+
method,
|
|
30
|
+
headers,
|
|
31
|
+
body: method === "GET" || method === "HEAD" ? undefined : Buffer.concat(chunks),
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run the CA on Node.js, created with the package's Node.js helpers and an
|
|
37
|
+
* in-memory `node:sqlite` database, behind an HTTP server on a random port.
|
|
38
|
+
*
|
|
39
|
+
* Output the CA writes with `console` is written to `log` until the server is
|
|
40
|
+
* closed.
|
|
41
|
+
*
|
|
42
|
+
* @param config The test server configuration
|
|
43
|
+
* @param log The log file for the CA and harness
|
|
44
|
+
* @returns The running server
|
|
45
|
+
* @throws InvalidConfigurationError if `config.bindings` is not a valid CA configuration
|
|
46
|
+
*/
|
|
47
|
+
export const startNode = async (config: TestServerConfig, log: LogFile): Promise<TestServer> => {
|
|
48
|
+
const db = new DatabaseSync(":memory:")
|
|
49
|
+
|
|
50
|
+
// bindingsFromEnv validates the configuration so a mistake fails at start up
|
|
51
|
+
const ca = createSshCa(bindingsFromEnv({
|
|
52
|
+
DB: fromNodeSqlite(db),
|
|
53
|
+
PRIVATE_KEY: secretFromString(config.private_key),
|
|
54
|
+
}, config.bindings))
|
|
55
|
+
|
|
56
|
+
// the CA logs JSON lines via console, so send them to the log file
|
|
57
|
+
const original = consoleMethods.map(level => [level, console[level]] as const)
|
|
58
|
+
for (const level of consoleMethods) {
|
|
59
|
+
console[level] = (...args: unknown[]): void => log.write(formatArgs(args))
|
|
60
|
+
}
|
|
61
|
+
const restoreConsole = (): void => {
|
|
62
|
+
for (const [level, method] of original) {
|
|
63
|
+
console[level] = method
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const server = createServer(async (req, res) => {
|
|
68
|
+
try {
|
|
69
|
+
const request = await toRequest(req)
|
|
70
|
+
const response = await ca.fetch(request)
|
|
71
|
+
const body = Buffer.from(await response.arrayBuffer())
|
|
72
|
+
|
|
73
|
+
log.request(request.method, req.url ?? "/", response.status, body.toString())
|
|
74
|
+
|
|
75
|
+
res.writeHead(response.status, Object.fromEntries(response.headers))
|
|
76
|
+
res.end(body)
|
|
77
|
+
} catch (err) {
|
|
78
|
+
log.fault("request failed", err)
|
|
79
|
+
res.writeHead(502)
|
|
80
|
+
res.end()
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await new Promise<void>((resolve, reject) => {
|
|
86
|
+
server.once("error", reject)
|
|
87
|
+
server.listen(0, "127.0.0.1", () => resolve())
|
|
88
|
+
})
|
|
89
|
+
} catch (err) {
|
|
90
|
+
restoreConsole()
|
|
91
|
+
db.close()
|
|
92
|
+
throw err
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const address = server.address()
|
|
96
|
+
if (address === null || typeof address === "string") {
|
|
97
|
+
restoreConsole()
|
|
98
|
+
server.close()
|
|
99
|
+
db.close()
|
|
100
|
+
throw new Error(`unexpected server address: ${String(address)}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
port: address.port,
|
|
105
|
+
close: async (): Promise<void> => {
|
|
106
|
+
server.closeAllConnections()
|
|
107
|
+
await new Promise<void>(resolve => server.close(() => resolve()))
|
|
108
|
+
restoreConsole()
|
|
109
|
+
db.close()
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/server.ts
ADDED
package/src/worker.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Worker entry point used by startWorkerd, bundled by Wrangler when the test
|
|
2
|
+
// server starts.
|
|
3
|
+
//
|
|
4
|
+
// The CA's default export is used as on Cloudflare Workers, wrapped only to
|
|
5
|
+
// log each request and to write console output as JSON lines, as the Node.js
|
|
6
|
+
// runtime does.
|
|
7
|
+
import ca from "@andrewheberle/serverless-ssh-ca"
|
|
8
|
+
import { formatArgs, formatRequest } from "./format.js"
|
|
9
|
+
|
|
10
|
+
type FetchParameters = Parameters<typeof ca.fetch>
|
|
11
|
+
|
|
12
|
+
// workerd formats objects passed to console for display rather than as JSON,
|
|
13
|
+
// so format them here
|
|
14
|
+
for (const level of ["debug", "info", "log", "warn", "error"] as const) {
|
|
15
|
+
const write = console[level].bind(console)
|
|
16
|
+
console[level] = (...args: unknown[]): void => write(formatArgs(args))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
...ca,
|
|
21
|
+
async fetch(request: FetchParameters[0], env: FetchParameters[1], ctx: FetchParameters[2]): Promise<Response> {
|
|
22
|
+
const response = await ca.fetch(request, env, ctx)
|
|
23
|
+
const url = new URL(request.url)
|
|
24
|
+
const body = response.status >= 400 ? await response.clone().text() : ""
|
|
25
|
+
|
|
26
|
+
console.log(formatRequest(request.method, url.pathname + url.search, response.status, body))
|
|
27
|
+
|
|
28
|
+
return response
|
|
29
|
+
},
|
|
30
|
+
}
|
package/src/workerd.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url"
|
|
2
|
+
import { createTestHarness } from "wrangler"
|
|
3
|
+
import type { TestServerConfig } from "./config.js"
|
|
4
|
+
import type { LogFile } from "./log.js"
|
|
5
|
+
import type { TestServer } from "./server.js"
|
|
6
|
+
|
|
7
|
+
// the Secrets Store binding exposes an admin API in local development, used
|
|
8
|
+
// to create the CA private key secret
|
|
9
|
+
type SecretsStoreAdmin = {
|
|
10
|
+
create(value: string): Promise<string>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type WorkerEnv = {
|
|
14
|
+
PRIVATE_KEY: {
|
|
15
|
+
"SecretsStoreSecret::admin_api": () => Promise<SecretsStoreAdmin>
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The Workers compatibility date the CA is run with, which matches the
|
|
21
|
+
* configuration the CA is tested with on Workers.
|
|
22
|
+
*/
|
|
23
|
+
export const COMPATIBILITY_DATE = "2026-03-13"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run the CA under workerd, the Cloudflare Workers runtime, using the Wrangler
|
|
27
|
+
* test harness as "wrangler dev" would, with a local D1 database and Secrets
|
|
28
|
+
* Store that are not persisted.
|
|
29
|
+
*
|
|
30
|
+
* The CA's output is copied to `log` every 100ms and when the server is
|
|
31
|
+
* closed. Errors from workerd itself, such as an uncaught exception, are
|
|
32
|
+
* logged as harness faults.
|
|
33
|
+
*
|
|
34
|
+
* @param config The test server configuration
|
|
35
|
+
* @param log The log file for the CA and harness
|
|
36
|
+
* @param root The directory Wrangler's working files are written to
|
|
37
|
+
* @returns The running server
|
|
38
|
+
*/
|
|
39
|
+
export const startWorkerd = async (config: TestServerConfig, log: LogFile, root: string): Promise<TestServer> => {
|
|
40
|
+
// use placeholder request.cf data rather than downloading it from
|
|
41
|
+
// Cloudflare and caching it in the working directory
|
|
42
|
+
process.env.CLOUDFLARE_CF_FETCH_ENABLED = "false"
|
|
43
|
+
|
|
44
|
+
const server = createTestHarness({
|
|
45
|
+
root,
|
|
46
|
+
workers: [{
|
|
47
|
+
config: {
|
|
48
|
+
name: "serverless-ssh-ca",
|
|
49
|
+
main: fileURLToPath(new URL("./worker.js", import.meta.url)),
|
|
50
|
+
compatibility_date: COMPATIBILITY_DATE,
|
|
51
|
+
compatibility_flags: ["nodejs_compat"],
|
|
52
|
+
vars: config.bindings,
|
|
53
|
+
d1_databases: [{
|
|
54
|
+
binding: "DB",
|
|
55
|
+
database_name: "ssh-ca-database",
|
|
56
|
+
database_id: "00000000-0000-0000-0000-000000000000",
|
|
57
|
+
}],
|
|
58
|
+
secrets_store_secrets: [{
|
|
59
|
+
binding: "PRIVATE_KEY",
|
|
60
|
+
store_id: "test",
|
|
61
|
+
secret_name: "ssh-ca-private-key",
|
|
62
|
+
}],
|
|
63
|
+
},
|
|
64
|
+
}],
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// copy the runtime logs to the log file. Errors that are not JSON lines
|
|
68
|
+
// from the CA are from workerd, such as an uncaught exception, so are
|
|
69
|
+
// logged as a harness fault.
|
|
70
|
+
const flushLogs = (): void => {
|
|
71
|
+
for (const entry of server.getLogs()) {
|
|
72
|
+
let json = true
|
|
73
|
+
try {
|
|
74
|
+
JSON.parse(entry.message)
|
|
75
|
+
} catch {
|
|
76
|
+
json = false
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (json || entry.level !== "error") {
|
|
80
|
+
log.write(entry.message)
|
|
81
|
+
} else {
|
|
82
|
+
log.fault("runtime error", entry.message)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
server.clearLogs()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const { url } = await server.listen()
|
|
90
|
+
|
|
91
|
+
// the secret must exist before the port is reported and requests are made
|
|
92
|
+
const env = await server.getWorker<WorkerEnv>().getEnv()
|
|
93
|
+
const secrets = await env.PRIVATE_KEY["SecretsStoreSecret::admin_api"]()
|
|
94
|
+
await secrets.create(config.private_key)
|
|
95
|
+
|
|
96
|
+
const timer = setInterval(flushLogs, 100)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
port: Number(url.port),
|
|
100
|
+
close: async (): Promise<void> => {
|
|
101
|
+
clearInterval(timer)
|
|
102
|
+
flushLogs()
|
|
103
|
+
await server.close()
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
flushLogs()
|
|
108
|
+
await server.close()
|
|
109
|
+
throw err
|
|
110
|
+
}
|
|
111
|
+
}
|