@andrewheberle/serverless-ssh-ca 0.19.0 → 0.20.0
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 +60 -3
- package/dist/index.d.ts +28 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -1
- package/dist/node/env.d.ts +72 -0
- package/dist/node/env.d.ts.map +1 -0
- package/dist/node/env.js +74 -0
- package/dist/node/env.js.map +1 -0
- package/dist/node/index.d.ts +23 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +22 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/secret.d.ts +24 -0
- package/dist/node/secret.d.ts.map +1 -0
- package/dist/node/secret.js +28 -0
- package/dist/node/secret.js.map +1 -0
- package/dist/node/sqlite.d.ts +47 -0
- package/dist/node/sqlite.d.ts.map +1 -0
- package/dist/node/sqlite.js +273 -0
- package/dist/node/sqlite.js.map +1 -0
- package/dist/types.d.ts +46 -3
- package/dist/types.d.ts.map +1 -1
- package/openapi.json +1391 -0
- package/package.json +11 -5
- package/src/index.ts +37 -1
- package/src/node/env.ts +90 -0
- package/src/node/index.ts +22 -0
- package/src/node/secret.ts +30 -0
- package/src/node/sqlite.ts +315 -0
- package/src/types.ts +49 -3
package/package.json
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrewheberle/serverless-ssh-ca",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A serverless SSH CA built
|
|
3
|
+
"version": "0.20.0",
|
|
4
|
+
"description": "A serverless SSH CA built for Cloudflare Workers that can also run on Node.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
8
8
|
"dist",
|
|
9
|
-
"src"
|
|
9
|
+
"src",
|
|
10
|
+
"openapi.json"
|
|
10
11
|
],
|
|
11
12
|
"exports": {
|
|
12
13
|
".": {
|
|
13
14
|
"types": "./dist/index.d.ts",
|
|
14
15
|
"default": "./dist/index.js"
|
|
15
|
-
}
|
|
16
|
+
},
|
|
17
|
+
"./node": {
|
|
18
|
+
"types": "./dist/node/index.d.ts",
|
|
19
|
+
"default": "./dist/node/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./openapi.json": "./openapi.json"
|
|
16
22
|
},
|
|
17
23
|
"scripts": {
|
|
18
24
|
"build": "tsc",
|
|
19
25
|
"coverage": "vitest run --coverage",
|
|
20
26
|
"lint": "eslint",
|
|
21
|
-
"prepublishOnly": "npm run build",
|
|
27
|
+
"prepublishOnly": "npm run build && npm run schema",
|
|
22
28
|
"release": "release-it",
|
|
23
29
|
"release:ci": "npm run release:patch",
|
|
24
30
|
"release:ci:major": "release-it major --ci",
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ExecutionContext } from "hono"
|
|
|
2
2
|
import { dbCleanup } from "./db/index.js"
|
|
3
3
|
import { createApp } from "./router.js"
|
|
4
4
|
import type { SshCaBindings } from "./types.js"
|
|
5
|
-
export type { CaDatabase, CaSecret, SshCaBindings } from "./types.js"
|
|
5
|
+
export type { CaDatabase, CaDatabaseQueryResult, CaDatabaseResult, CaPreparedStatement, CaSecret, SshCaBindings } from "./types.js"
|
|
6
6
|
|
|
7
7
|
// the handlers the CA exposes, compatible with a Cloudflare Workers ExportedHandler
|
|
8
8
|
type SshCaHandler = {
|
|
@@ -10,6 +10,42 @@ type SshCaHandler = {
|
|
|
10
10
|
scheduled(controller: unknown, env: SshCaBindings, ctx: ExecutionContext): Promise<void>
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* A runtime neutral instance of the CA, returned by {@link createSshCa}.
|
|
15
|
+
*/
|
|
16
|
+
export interface SshCa {
|
|
17
|
+
/**
|
|
18
|
+
* Handle a request to the CA's API, suitable for any server that speaks
|
|
19
|
+
* the Fetch API `Request` and `Response` types.
|
|
20
|
+
*/
|
|
21
|
+
fetch(req: Request): Promise<Response>
|
|
22
|
+
/**
|
|
23
|
+
* Remove expired certificates from the database according to
|
|
24
|
+
* `DB_CERTIFICATE_RETENTION`. This should be run periodically (for example
|
|
25
|
+
* once a day), as the `scheduled` handler does on Cloudflare Workers.
|
|
26
|
+
*/
|
|
27
|
+
cleanup(): Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create an instance of the CA bound to a fixed set of bindings, for use on
|
|
32
|
+
* runtimes other than Cloudflare Workers.
|
|
33
|
+
*
|
|
34
|
+
* On Workers use the default export instead, which receives its bindings from
|
|
35
|
+
* the runtime on each request.
|
|
36
|
+
*
|
|
37
|
+
* @param env The bindings (configuration, database and private key) for the CA, see {@link SshCaBindings}
|
|
38
|
+
* @returns The CA's request handler and database cleanup task, see {@link SshCa}
|
|
39
|
+
*/
|
|
40
|
+
export const createSshCa = (env: SshCaBindings): SshCa => {
|
|
41
|
+
const app = createApp(env)
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
fetch: async (req: Request): Promise<Response> => await app.fetch(req, env),
|
|
45
|
+
cleanup: async (): Promise<void> => await dbCleanup(env),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
13
49
|
let app: ReturnType<typeof createApp> | undefined
|
|
14
50
|
|
|
15
51
|
export default {
|
package/src/node/env.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { seconds } from "itty-time"
|
|
2
|
+
import z from "zod"
|
|
3
|
+
import type { CaDatabase, CaSecret, SshCaBindings } from "../types.js"
|
|
4
|
+
|
|
5
|
+
const required = z.string().trim().min(1, { message: "must be set" })
|
|
6
|
+
|
|
7
|
+
// a duration in the format accepted by itty-time, such as "24 hours"
|
|
8
|
+
const duration = (opts: { allowZero: boolean }) => required.refine(v => {
|
|
9
|
+
const s = seconds(v)
|
|
10
|
+
return Number.isFinite(s) && (opts.allowZero ? s >= 0 : s > 0)
|
|
11
|
+
}, { message: opts.allowZero ? "must be a duration such as \"90 seconds\"" : "must be a positive duration such as \"24 hours\"" })
|
|
12
|
+
|
|
13
|
+
const boolean = z.enum(["true", "false"], { message: "must be \"true\" or \"false\"" })
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The environment variables read by {@link bindingsFromEnv}, which match the
|
|
17
|
+
* Workers vars documented for the CA.
|
|
18
|
+
*/
|
|
19
|
+
export const EnvSchema = z.object({
|
|
20
|
+
DB_CERTIFICATE_RETENTION: z.union([
|
|
21
|
+
z.literal("infinite"),
|
|
22
|
+
// an SQLite date modifier such as "1 year" or "90 days"
|
|
23
|
+
z.string().regex(/^[+-]?\d+(\.\d+)? (second|minute|hour|day|month|year)s?$/),
|
|
24
|
+
], { message: "must be \"infinite\" or a period such as \"1 year\"" }),
|
|
25
|
+
SSH_CERTIFICATE_EXTENSIONS: z.string(),
|
|
26
|
+
SSH_CERTIFICATE_LIFETIME: duration({ allowZero: false }),
|
|
27
|
+
SSH_CERTIFICATE_INCLUDE_SELF: boolean.optional(),
|
|
28
|
+
SSH_CERTIFICATE_INCLUDE_SELF_EMAIL: boolean.optional(),
|
|
29
|
+
SSH_CERTIFICATE_PRINCIPALS: z.string().optional(),
|
|
30
|
+
SSH_HOST_CERTIFICATE_LIFETIME: duration({ allowZero: false }),
|
|
31
|
+
SSH_HOST_CERTIFICATE_ALLOWED_EMAILS: z.string().optional(),
|
|
32
|
+
SSH_HOST_CERTIFICATE_ALLOWED_ROLES: z.string().optional(),
|
|
33
|
+
ISSUER_DN: required,
|
|
34
|
+
JWT_JWKS_URL: z.url({ protocol: /^https?$/, message: "must be an http or https URL" }),
|
|
35
|
+
JWT_AUD: z.string().optional(),
|
|
36
|
+
JWT_ISSUER: required,
|
|
37
|
+
JWT_ALGORITHMS: required,
|
|
38
|
+
JWT_SSH_CERTIFICATE_PRINCIPALS_CLAIM: required,
|
|
39
|
+
CERTIFICATE_REQUEST_TIME_SKEW_MAX: duration({ allowZero: true }),
|
|
40
|
+
LOG_LEVEL: z.enum(["none", "error", "warning", "info", "debug"]).optional(),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The CA's configuration as read from environment variables, see {@link EnvSchema}.
|
|
45
|
+
*/
|
|
46
|
+
export type CaEnv = z.infer<typeof EnvSchema>
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Thrown by {@link bindingsFromEnv} when the environment is not a valid configuration.
|
|
50
|
+
*/
|
|
51
|
+
export class InvalidConfigurationError extends Error {
|
|
52
|
+
constructor(message: string) {
|
|
53
|
+
super(message)
|
|
54
|
+
this.name = "InvalidConfigurationError"
|
|
55
|
+
|
|
56
|
+
Object.setPrototypeOf(this, InvalidConfigurationError.prototype)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the CA's bindings from environment variables, for runtimes where
|
|
62
|
+
* configuration is not supplied as Workers bindings.
|
|
63
|
+
*
|
|
64
|
+
* The variables have the same names and formats as the Workers vars, and are
|
|
65
|
+
* validated before use so that misconfiguration is reported at start up.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* const env = bindingsFromEnv({
|
|
70
|
+
* DB: fromNodeSqlite(new DatabaseSync("ssh-ca.sqlite")),
|
|
71
|
+
* PRIVATE_KEY: secretFromFile("/etc/ssh-ca/ca_key"),
|
|
72
|
+
* })
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* @param bindings The database and private key, see {@link CaDatabase} and {@link CaSecret}
|
|
76
|
+
* @param source The environment variables to read, `process.env` by default
|
|
77
|
+
* @returns The bindings to pass to `createSshCa`
|
|
78
|
+
* @throws {@link InvalidConfigurationError} if a variable is missing or invalid
|
|
79
|
+
*/
|
|
80
|
+
export const bindingsFromEnv = (
|
|
81
|
+
bindings: { DB: CaDatabase, PRIVATE_KEY: CaSecret },
|
|
82
|
+
source: Record<string, string | undefined> = process.env,
|
|
83
|
+
): SshCaBindings => {
|
|
84
|
+
const result = EnvSchema.safeParse(source)
|
|
85
|
+
if (!result.success) {
|
|
86
|
+
throw new InvalidConfigurationError(`invalid configuration:\n${z.prettifyError(result.error)}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ...result.data, DB: bindings.DB, PRIVATE_KEY: bindings.PRIVATE_KEY }
|
|
90
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for running the CA on Node.js, imported from
|
|
3
|
+
* `@andrewheberle/serverless-ssh-ca/node`.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { DatabaseSync } from "node:sqlite"
|
|
8
|
+
* import { createSshCa } from "@andrewheberle/serverless-ssh-ca"
|
|
9
|
+
* import { bindingsFromEnv, fromNodeSqlite, secretFromFile } from "@andrewheberle/serverless-ssh-ca/node"
|
|
10
|
+
*
|
|
11
|
+
* const ca = createSshCa(bindingsFromEnv({
|
|
12
|
+
* DB: fromNodeSqlite(new DatabaseSync("ssh-ca.sqlite")),
|
|
13
|
+
* PRIVATE_KEY: secretFromFile("/etc/ssh-ca/ca_key"),
|
|
14
|
+
* }))
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
export { bindingsFromEnv, EnvSchema, InvalidConfigurationError } from "./env.js"
|
|
20
|
+
export type { CaEnv } from "./env.js"
|
|
21
|
+
export { secretFromFile, secretFromString } from "./secret.js"
|
|
22
|
+
export { fromNodeSqlite } from "./sqlite.js"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
import type { CaSecret } from "../types.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Use a string as the CA's private key secret.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* const PRIVATE_KEY = secretFromString(process.env.SSH_CA_PRIVATE_KEY ?? "")
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* @param value The CA private key in OpenSSH format
|
|
13
|
+
* @returns A secret for the `PRIVATE_KEY` binding, see {@link CaSecret}
|
|
14
|
+
*/
|
|
15
|
+
export const secretFromString = (value: string): CaSecret => ({
|
|
16
|
+
get: async (): Promise<string> => value,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Use the contents of a file as the CA's private key secret.
|
|
21
|
+
*
|
|
22
|
+
* The file is read each time the key is needed, so replacing the file takes
|
|
23
|
+
* effect without a restart.
|
|
24
|
+
*
|
|
25
|
+
* @param path The path of a file containing the CA private key in OpenSSH format
|
|
26
|
+
* @returns A secret for the `PRIVATE_KEY` binding, see {@link CaSecret}
|
|
27
|
+
*/
|
|
28
|
+
export const secretFromFile = (path: string): CaSecret => ({
|
|
29
|
+
get: async (): Promise<string> => await readFile(path, { encoding: "utf-8" }),
|
|
30
|
+
})
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import type { DatabaseSync, SQLInputValue } from "node:sqlite"
|
|
2
|
+
import type { CaDatabase, CaDatabaseQueryResult, CaDatabaseResult, CaPreparedStatement } from "../types.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A single SQL statement split from a larger query by {@link splitStatements}.
|
|
6
|
+
*/
|
|
7
|
+
export type SqlStatement = {
|
|
8
|
+
/** The SQL of the statement, without its trailing `;` */
|
|
9
|
+
sql: string
|
|
10
|
+
/** The number of anonymous (`?`) parameters in the statement */
|
|
11
|
+
parameters: number
|
|
12
|
+
/** Whether the statement uses numbered or named parameters (`?1`, `:name`, `@name` or `$name`) */
|
|
13
|
+
named: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const isWordChar = (ch: string | undefined): boolean => ch !== undefined && /[A-Za-z0-9_$]/.test(ch)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Split SQL that may contain several statements into individual statements.
|
|
20
|
+
*
|
|
21
|
+
* `node:sqlite` only compiles the first statement passed to `prepare()`,
|
|
22
|
+
* whereas Cloudflare D1 runs them all, so queries are split here first. Quoted
|
|
23
|
+
* strings and identifiers, comments and `CREATE TRIGGER ... BEGIN ... END`
|
|
24
|
+
* bodies are not split. Statements containing nothing but whitespace or
|
|
25
|
+
* comments are dropped.
|
|
26
|
+
*
|
|
27
|
+
* @param sql The SQL to split
|
|
28
|
+
* @returns The statements found in `sql`, see {@link SqlStatement}
|
|
29
|
+
*/
|
|
30
|
+
export const splitStatements = (sql: string): SqlStatement[] => {
|
|
31
|
+
const statements: SqlStatement[] = []
|
|
32
|
+
|
|
33
|
+
let start = 0
|
|
34
|
+
let parameters = 0
|
|
35
|
+
let named = false
|
|
36
|
+
let hasTokens = false
|
|
37
|
+
let words: string[] = []
|
|
38
|
+
let depth = 0
|
|
39
|
+
|
|
40
|
+
const push = (end: number): void => {
|
|
41
|
+
if (hasTokens) {
|
|
42
|
+
statements.push({ sql: sql.slice(start, end).trim(), parameters, named })
|
|
43
|
+
}
|
|
44
|
+
start = end + 1
|
|
45
|
+
parameters = 0
|
|
46
|
+
named = false
|
|
47
|
+
hasTokens = false
|
|
48
|
+
words = []
|
|
49
|
+
depth = 0
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// true if the current statement is CREATE [TEMP|TEMPORARY] TRIGGER, whose body may contain ";"
|
|
53
|
+
const inTrigger = (): boolean => words[0] === "CREATE" && (words[1] === "TRIGGER" || words[2] === "TRIGGER")
|
|
54
|
+
|
|
55
|
+
let i = 0
|
|
56
|
+
while (i < sql.length) {
|
|
57
|
+
const ch = sql[i]
|
|
58
|
+
const next = sql[i + 1]
|
|
59
|
+
|
|
60
|
+
// comments
|
|
61
|
+
if (ch === "-" && next === "-") {
|
|
62
|
+
const end = sql.indexOf("\n", i)
|
|
63
|
+
i = end === -1 ? sql.length : end + 1
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if (ch === "/" && next === "*") {
|
|
67
|
+
const end = sql.indexOf("*/", i + 2)
|
|
68
|
+
i = end === -1 ? sql.length : end + 2
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// whitespace
|
|
73
|
+
if (ch === undefined || /\s/.test(ch)) {
|
|
74
|
+
i++
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// end of statement
|
|
79
|
+
if (ch === ";" && depth === 0) {
|
|
80
|
+
push(i)
|
|
81
|
+
i++
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
hasTokens = true
|
|
86
|
+
|
|
87
|
+
// quoted strings and identifiers, where a doubled quote is an escaped quote
|
|
88
|
+
if (ch === "'" || ch === "\"" || ch === "`" || ch === "[") {
|
|
89
|
+
const close = ch === "[" ? "]" : ch
|
|
90
|
+
let j = i + 1
|
|
91
|
+
while (j < sql.length) {
|
|
92
|
+
if (sql[j] === close) {
|
|
93
|
+
if (close !== "]" && sql[j + 1] === close) {
|
|
94
|
+
j += 2
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
j++
|
|
100
|
+
}
|
|
101
|
+
i = j + 1
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// parameters
|
|
106
|
+
if (ch === "?") {
|
|
107
|
+
let j = i + 1
|
|
108
|
+
while (j < sql.length && /[0-9]/.test(sql[j] ?? "")) {
|
|
109
|
+
j++
|
|
110
|
+
}
|
|
111
|
+
if (j === i + 1) {
|
|
112
|
+
parameters++
|
|
113
|
+
} else {
|
|
114
|
+
named = true
|
|
115
|
+
}
|
|
116
|
+
i = j
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if ((ch === ":" || ch === "@" || ch === "$") && isWordChar(next)) {
|
|
120
|
+
named = true
|
|
121
|
+
i++
|
|
122
|
+
while (isWordChar(sql[i])) {
|
|
123
|
+
i++
|
|
124
|
+
}
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// keywords and identifiers
|
|
129
|
+
if (isWordChar(ch)) {
|
|
130
|
+
let j = i
|
|
131
|
+
while (isWordChar(sql[j])) {
|
|
132
|
+
j++
|
|
133
|
+
}
|
|
134
|
+
const word = sql.slice(i, j).toUpperCase()
|
|
135
|
+
if (words.length < 3) {
|
|
136
|
+
words.push(word)
|
|
137
|
+
}
|
|
138
|
+
if (inTrigger()) {
|
|
139
|
+
if (word === "BEGIN" || word === "CASE") {
|
|
140
|
+
depth++
|
|
141
|
+
} else if (word === "END" && depth > 0) {
|
|
142
|
+
depth--
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
i = j
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
i++
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
push(sql.length)
|
|
153
|
+
|
|
154
|
+
return statements
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Convert a value bound to a statement into one `node:sqlite` accepts, using
|
|
159
|
+
* the same conversions as Cloudflare D1.
|
|
160
|
+
*/
|
|
161
|
+
const toSqlValue = (value: unknown): SQLInputValue => {
|
|
162
|
+
if (value === null || typeof value === "number" || typeof value === "bigint" || typeof value === "string") {
|
|
163
|
+
return value
|
|
164
|
+
}
|
|
165
|
+
if (typeof value === "boolean") {
|
|
166
|
+
return value ? 1 : 0
|
|
167
|
+
}
|
|
168
|
+
if (value instanceof ArrayBuffer) {
|
|
169
|
+
return new Uint8Array(value)
|
|
170
|
+
}
|
|
171
|
+
if (ArrayBuffer.isView(value)) {
|
|
172
|
+
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
|
173
|
+
}
|
|
174
|
+
throw new TypeError(`unsupported type for SQL parameter: ${value === undefined ? "undefined" : typeof value}`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const toNumber = (value: unknown): number => {
|
|
178
|
+
if (typeof value === "number") {
|
|
179
|
+
return value
|
|
180
|
+
}
|
|
181
|
+
if (typeof value === "bigint") {
|
|
182
|
+
return Number(value)
|
|
183
|
+
}
|
|
184
|
+
throw new TypeError(`expected a number from SQLite but got ${typeof value}`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// counters used to report D1 style metadata for any statement
|
|
188
|
+
const counters = (db: DatabaseSync): { changes: number, lastRowId: number } => {
|
|
189
|
+
const row = db.prepare("SELECT total_changes() AS changes, last_insert_rowid() AS last_row_id").get()
|
|
190
|
+
return { changes: toNumber(row?.changes), lastRowId: toNumber(row?.last_row_id) }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let savepointId = 0
|
|
194
|
+
|
|
195
|
+
// run fn atomically, using a savepoint so it also works inside an existing transaction
|
|
196
|
+
const atomically = <T>(db: DatabaseSync, fn: () => T): T => {
|
|
197
|
+
const name = `serverless_ssh_ca_${savepointId++}`
|
|
198
|
+
db.exec(`SAVEPOINT ${name}`)
|
|
199
|
+
try {
|
|
200
|
+
const result = fn()
|
|
201
|
+
db.exec(`RELEASE ${name}`)
|
|
202
|
+
return result
|
|
203
|
+
} catch (err) {
|
|
204
|
+
db.exec(`ROLLBACK TO ${name}`)
|
|
205
|
+
db.exec(`RELEASE ${name}`)
|
|
206
|
+
throw err
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
class NodeSqliteStatement implements CaPreparedStatement {
|
|
211
|
+
readonly db: DatabaseSync
|
|
212
|
+
private readonly sql: string
|
|
213
|
+
private readonly values: readonly SQLInputValue[]
|
|
214
|
+
|
|
215
|
+
constructor(db: DatabaseSync, sql: string, values: readonly SQLInputValue[] = []) {
|
|
216
|
+
this.db = db
|
|
217
|
+
this.sql = sql
|
|
218
|
+
this.values = values
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
bind(...values: unknown[]): NodeSqliteStatement {
|
|
222
|
+
return new NodeSqliteStatement(this.db, this.sql, values.map(toSqlValue))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async all(): Promise<CaDatabaseQueryResult> {
|
|
226
|
+
return this.execute()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async run(): Promise<CaDatabaseResult> {
|
|
230
|
+
const { success, meta } = this.execute()
|
|
231
|
+
return { success, meta }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// executes synchronously so no other query can run part way through
|
|
235
|
+
execute(): CaDatabaseQueryResult {
|
|
236
|
+
const started = performance.now()
|
|
237
|
+
const before = counters(this.db)
|
|
238
|
+
const results = this.query()
|
|
239
|
+
const after = counters(this.db)
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
success: true,
|
|
243
|
+
results,
|
|
244
|
+
meta: {
|
|
245
|
+
changes: after.changes - before.changes,
|
|
246
|
+
last_row_id: after.lastRowId,
|
|
247
|
+
duration: performance.now() - started,
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private query(): unknown[] {
|
|
253
|
+
const statements = splitStatements(this.sql)
|
|
254
|
+
if (statements.length <= 1) {
|
|
255
|
+
return this.db.prepare(this.sql).all(...this.values)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (statements.some(s => s.named)) {
|
|
259
|
+
throw new Error("queries containing more than one statement only support anonymous (?) parameters")
|
|
260
|
+
}
|
|
261
|
+
const expected = statements.reduce((total, s) => total + s.parameters, 0)
|
|
262
|
+
if (expected !== this.values.length) {
|
|
263
|
+
throw new RangeError(`query expects ${expected} parameters but ${this.values.length} were bound`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// give each statement its share of the bound values, returning the rows of the last statement
|
|
267
|
+
return atomically(this.db, () => {
|
|
268
|
+
let offset = 0
|
|
269
|
+
let results: unknown[] = []
|
|
270
|
+
for (const statement of statements) {
|
|
271
|
+
const values = this.values.slice(offset, offset + statement.parameters)
|
|
272
|
+
offset += statement.parameters
|
|
273
|
+
results = this.db.prepare(statement.sql).all(...values)
|
|
274
|
+
}
|
|
275
|
+
return results
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Use a Node.js `node:sqlite` database as the CA's database.
|
|
282
|
+
*
|
|
283
|
+
* The returned object behaves like a Cloudflare D1 binding: queries containing
|
|
284
|
+
* several statements are run in full (see {@link splitStatements}) and
|
|
285
|
+
* {@link CaDatabase.batch} is atomic. The database's schema is created and
|
|
286
|
+
* migrated automatically when the CA first uses it.
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* ```ts
|
|
290
|
+
* import { DatabaseSync } from "node:sqlite"
|
|
291
|
+
* import { fromNodeSqlite } from "@andrewheberle/serverless-ssh-ca/node"
|
|
292
|
+
*
|
|
293
|
+
* const DB = fromNodeSqlite(new DatabaseSync("ssh-ca.sqlite"))
|
|
294
|
+
* ```
|
|
295
|
+
*
|
|
296
|
+
* @param db An open `node:sqlite` database
|
|
297
|
+
* @returns A database for the `DB` binding, see {@link CaDatabase}
|
|
298
|
+
*/
|
|
299
|
+
export const fromNodeSqlite = (db: DatabaseSync): CaDatabase => ({
|
|
300
|
+
prepare: (query: string): CaPreparedStatement => new NodeSqliteStatement(db, query),
|
|
301
|
+
batch: async (statements: CaPreparedStatement[]): Promise<CaDatabaseQueryResult[]> => {
|
|
302
|
+
const owned = statements.map(s => {
|
|
303
|
+
if (!(s instanceof NodeSqliteStatement) || s.db !== db) {
|
|
304
|
+
throw new TypeError("batch statements must be prepared by the same database")
|
|
305
|
+
}
|
|
306
|
+
return s
|
|
307
|
+
})
|
|
308
|
+
return atomically(db, () => owned.map(s => s.execute()))
|
|
309
|
+
},
|
|
310
|
+
exec: async (query: string): Promise<{ count: number, duration: number }> => {
|
|
311
|
+
const started = performance.now()
|
|
312
|
+
db.exec(query)
|
|
313
|
+
return { count: splitStatements(query).length, duration: performance.now() - started }
|
|
314
|
+
},
|
|
315
|
+
})
|
package/src/types.ts
CHANGED
|
@@ -26,15 +26,61 @@ export type SSHExtension = {
|
|
|
26
26
|
data: Buffer<ArrayBuffer>
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The result of executing a statement via {@link CaPreparedStatement.run} or
|
|
31
|
+
* {@link CaDatabase.batch}, modelled on a Cloudflare D1 result.
|
|
32
|
+
*/
|
|
33
|
+
export interface CaDatabaseResult {
|
|
34
|
+
/** Whether the statement executed successfully */
|
|
35
|
+
success: boolean
|
|
36
|
+
meta?: {
|
|
37
|
+
/** The number of rows changed by the statement */
|
|
38
|
+
changes?: number
|
|
39
|
+
/** The rowid of the last row inserted */
|
|
40
|
+
last_row_id?: number
|
|
41
|
+
/** How long the statement took to execute in milliseconds */
|
|
42
|
+
duration?: number
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The result of executing a statement that returns rows via
|
|
48
|
+
* {@link CaPreparedStatement.all}.
|
|
49
|
+
*/
|
|
50
|
+
export interface CaDatabaseQueryResult extends CaDatabaseResult {
|
|
51
|
+
/** The rows returned, one object per row keyed by column name */
|
|
52
|
+
results: unknown[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The subset of a Cloudflare D1 prepared statement used by the CA.
|
|
57
|
+
*/
|
|
58
|
+
export interface CaPreparedStatement {
|
|
59
|
+
/** Bind positional (`?`) parameters, returning a statement to execute */
|
|
60
|
+
bind(...values: unknown[]): CaPreparedStatement
|
|
61
|
+
/** Execute the statement and return all rows */
|
|
62
|
+
all(): Promise<CaDatabaseQueryResult>
|
|
63
|
+
/** Execute the statement without returning rows */
|
|
64
|
+
run(): Promise<CaDatabaseResult>
|
|
65
|
+
}
|
|
66
|
+
|
|
29
67
|
/**
|
|
30
68
|
* The subset of a Cloudflare D1 database binding used by the CA.
|
|
31
69
|
*
|
|
32
70
|
* A Workers `D1Database` binding satisfies this structurally, so the package
|
|
33
|
-
* does not need to depend on `@cloudflare/workers-types`.
|
|
71
|
+
* does not need to depend on `@cloudflare/workers-types`. Any other SQLite
|
|
72
|
+
* compatible database may be used by implementing this interface.
|
|
73
|
+
*
|
|
74
|
+
* The SQL the CA runs is SQLite dialect, and statements passed to
|
|
75
|
+
* {@link CaDatabase.prepare} may contain more than one SQL statement (as used
|
|
76
|
+
* when applying schema migrations).
|
|
34
77
|
*/
|
|
35
78
|
export interface CaDatabase {
|
|
36
|
-
|
|
37
|
-
|
|
79
|
+
/** Prepare a SQL query for execution, see {@link CaPreparedStatement} */
|
|
80
|
+
prepare(query: string): CaPreparedStatement
|
|
81
|
+
/** Execute several prepared statements atomically, returning a result for each */
|
|
82
|
+
batch(statements: CaPreparedStatement[]): Promise<CaDatabaseResult[]>
|
|
83
|
+
/** Execute one or more SQL statements without parameters */
|
|
38
84
|
exec(query: string): Promise<unknown>
|
|
39
85
|
}
|
|
40
86
|
|