@yannelli/zoomies 0.0.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/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/cli/client.d.ts +77 -0
- package/dist/cli/client.d.ts.map +1 -0
- package/dist/cli/client.js +300 -0
- package/dist/cli/client.js.map +1 -0
- package/dist/cli/commands/certs.d.ts +11 -0
- package/dist/cli/commands/certs.d.ts.map +1 -0
- package/dist/cli/commands/certs.js +110 -0
- package/dist/cli/commands/certs.js.map +1 -0
- package/dist/cli/commands/flags.d.ts +29 -0
- package/dist/cli/commands/flags.d.ts.map +1 -0
- package/dist/cli/commands/flags.js +104 -0
- package/dist/cli/commands/flags.js.map +1 -0
- package/dist/cli/commands/reload.d.ts +11 -0
- package/dist/cli/commands/reload.d.ts.map +1 -0
- package/dist/cli/commands/reload.js +35 -0
- package/dist/cli/commands/reload.js.map +1 -0
- package/dist/cli/commands/sites.d.ts +11 -0
- package/dist/cli/commands/sites.d.ts.map +1 -0
- package/dist/cli/commands/sites.js +221 -0
- package/dist/cli/commands/sites.js.map +1 -0
- package/dist/cli/commands/status.d.ts +10 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +41 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/upstreams.d.ts +21 -0
- package/dist/cli/commands/upstreams.d.ts.map +1 -0
- package/dist/cli/commands/upstreams.js +248 -0
- package/dist/cli/commands/upstreams.js.map +1 -0
- package/dist/cli/dispatcher.d.ts +45 -0
- package/dist/cli/dispatcher.d.ts.map +1 -0
- package/dist/cli/dispatcher.js +192 -0
- package/dist/cli/dispatcher.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/server/api/db-context.d.ts +50 -0
- package/dist/server/api/db-context.d.ts.map +1 -0
- package/dist/server/api/db-context.js +76 -0
- package/dist/server/api/db-context.js.map +1 -0
- package/dist/server/api/error-mapping.d.ts +19 -0
- package/dist/server/api/error-mapping.d.ts.map +1 -0
- package/dist/server/api/error-mapping.js +56 -0
- package/dist/server/api/error-mapping.js.map +1 -0
- package/dist/server/api/handlers/site-cert.d.ts +49 -0
- package/dist/server/api/handlers/site-cert.d.ts.map +1 -0
- package/dist/server/api/handlers/site-cert.js +54 -0
- package/dist/server/api/handlers/site-cert.js.map +1 -0
- package/dist/server/api/handlers/sites.d.ts +67 -0
- package/dist/server/api/handlers/sites.d.ts.map +1 -0
- package/dist/server/api/handlers/sites.js +78 -0
- package/dist/server/api/handlers/sites.js.map +1 -0
- package/dist/server/api/handlers/upstreams.d.ts +64 -0
- package/dist/server/api/handlers/upstreams.d.ts.map +1 -0
- package/dist/server/api/handlers/upstreams.js +97 -0
- package/dist/server/api/handlers/upstreams.js.map +1 -0
- package/dist/server/auth/require-token.d.ts +24 -0
- package/dist/server/auth/require-token.d.ts.map +1 -0
- package/dist/server/auth/require-token.js +98 -0
- package/dist/server/auth/require-token.js.map +1 -0
- package/dist/server/certs/acme-account.d.ts +37 -0
- package/dist/server/certs/acme-account.d.ts.map +1 -0
- package/dist/server/certs/acme-account.js +49 -0
- package/dist/server/certs/acme-account.js.map +1 -0
- package/dist/server/certs/challenge-store.d.ts +53 -0
- package/dist/server/certs/challenge-store.d.ts.map +1 -0
- package/dist/server/certs/challenge-store.js +66 -0
- package/dist/server/certs/challenge-store.js.map +1 -0
- package/dist/server/certs/issue.d.ts +106 -0
- package/dist/server/certs/issue.d.ts.map +1 -0
- package/dist/server/certs/issue.js +107 -0
- package/dist/server/certs/issue.js.map +1 -0
- package/dist/server/certs/renew.d.ts +34 -0
- package/dist/server/certs/renew.d.ts.map +1 -0
- package/dist/server/certs/renew.js +36 -0
- package/dist/server/certs/renew.js.map +1 -0
- package/dist/server/certs/scheduler.d.ts +68 -0
- package/dist/server/certs/scheduler.d.ts.map +1 -0
- package/dist/server/certs/scheduler.js +76 -0
- package/dist/server/certs/scheduler.js.map +1 -0
- package/dist/server/db/connection.d.ts +10 -0
- package/dist/server/db/connection.d.ts.map +1 -0
- package/dist/server/db/connection.js +16 -0
- package/dist/server/db/connection.js.map +1 -0
- package/dist/server/db/migrate.d.ts +12 -0
- package/dist/server/db/migrate.d.ts.map +1 -0
- package/dist/server/db/migrate.js +37 -0
- package/dist/server/db/migrate.js.map +1 -0
- package/dist/server/db/migrations/0001_init.sql +42 -0
- package/dist/server/domain/cert.d.ts +17 -0
- package/dist/server/domain/cert.d.ts.map +1 -0
- package/dist/server/domain/cert.js +22 -0
- package/dist/server/domain/cert.js.map +1 -0
- package/dist/server/domain/errors.d.ts +36 -0
- package/dist/server/domain/errors.d.ts.map +1 -0
- package/dist/server/domain/errors.js +37 -0
- package/dist/server/domain/errors.js.map +1 -0
- package/dist/server/domain/site.d.ts +15 -0
- package/dist/server/domain/site.d.ts.map +1 -0
- package/dist/server/domain/site.js +25 -0
- package/dist/server/domain/site.js.map +1 -0
- package/dist/server/domain/upstream.d.ts +25 -0
- package/dist/server/domain/upstream.d.ts.map +1 -0
- package/dist/server/domain/upstream.js +24 -0
- package/dist/server/domain/upstream.js.map +1 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +4 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/reload/atomic-write.d.ts +44 -0
- package/dist/server/reload/atomic-write.d.ts.map +1 -0
- package/dist/server/reload/atomic-write.js +151 -0
- package/dist/server/reload/atomic-write.js.map +1 -0
- package/dist/server/reload/health-probe.d.ts +62 -0
- package/dist/server/reload/health-probe.d.ts.map +1 -0
- package/dist/server/reload/health-probe.js +105 -0
- package/dist/server/reload/health-probe.js.map +1 -0
- package/dist/server/reload/reload.d.ts +118 -0
- package/dist/server/reload/reload.d.ts.map +1 -0
- package/dist/server/reload/reload.js +232 -0
- package/dist/server/reload/reload.js.map +1 -0
- package/dist/server/renderer/render-bundle.d.ts +18 -0
- package/dist/server/renderer/render-bundle.d.ts.map +1 -0
- package/dist/server/renderer/render-bundle.js +32 -0
- package/dist/server/renderer/render-bundle.js.map +1 -0
- package/dist/server/renderer/render-site.d.ts +5 -0
- package/dist/server/renderer/render-site.d.ts.map +1 -0
- package/dist/server/renderer/render-site.js +144 -0
- package/dist/server/renderer/render-site.js.map +1 -0
- package/dist/server/repositories/cert-repository.d.ts +19 -0
- package/dist/server/repositories/cert-repository.d.ts.map +1 -0
- package/dist/server/repositories/cert-repository.js +112 -0
- package/dist/server/repositories/cert-repository.js.map +1 -0
- package/dist/server/repositories/site-repository.d.ts +17 -0
- package/dist/server/repositories/site-repository.d.ts.map +1 -0
- package/dist/server/repositories/site-repository.js +122 -0
- package/dist/server/repositories/site-repository.js.map +1 -0
- package/dist/server/repositories/upstream-repository.d.ts +22 -0
- package/dist/server/repositories/upstream-repository.d.ts.map +1 -0
- package/dist/server/repositories/upstream-repository.js +142 -0
- package/dist/server/repositories/upstream-repository.js.map +1 -0
- package/dist/server/validator/nginx-binary.d.ts +9 -0
- package/dist/server/validator/nginx-binary.d.ts.map +1 -0
- package/dist/server/validator/nginx-binary.js +11 -0
- package/dist/server/validator/nginx-binary.js.map +1 -0
- package/dist/server/validator/validate.d.ts +29 -0
- package/dist/server/validator/validate.d.ts.map +1 -0
- package/dist/server/validator/validate.js +69 -0
- package/dist/server/validator/validate.js.map +1 -0
- package/dist/server/worker/main.d.ts +43 -0
- package/dist/server/worker/main.d.ts.map +1 -0
- package/dist/server/worker/main.js +181 -0
- package/dist/server/worker/main.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +84 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer-token guard for Route Handlers and CLI-facing entry points.
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally NOT a Next.js `middleware.ts` export — Next.js
|
|
5
|
+
* middleware runs in the Edge runtime, which doesn't expose Node's `crypto`
|
|
6
|
+
* primitives we need for a constant-time token comparison. Route Handlers
|
|
7
|
+
* call this helper directly in the Node runtime.
|
|
8
|
+
*/
|
|
9
|
+
import { Buffer } from 'node:buffer';
|
|
10
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
11
|
+
export class UnauthorizedError extends Error {
|
|
12
|
+
code = 'unauthorized';
|
|
13
|
+
constructor(message = 'unauthorized') {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'UnauthorizedError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Duck-typed check for a Web `Headers`-like value. We accept either a real
|
|
20
|
+
* `Headers` instance (Route Handlers) or a plain header bag (unit tests, CLI
|
|
21
|
+
* callers) so the same helper covers both surfaces.
|
|
22
|
+
*/
|
|
23
|
+
function isHeadersLike(value) {
|
|
24
|
+
return (typeof value === 'object' &&
|
|
25
|
+
value !== null &&
|
|
26
|
+
typeof value.get === 'function');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Look up the `Authorization` header from either input shape. Plain bags may
|
|
30
|
+
* use any casing and may store arrays (Node's `IncomingHttpHeaders` does for
|
|
31
|
+
* a few headers); take the first array entry if so.
|
|
32
|
+
*/
|
|
33
|
+
function readAuthorizationHeader(headers) {
|
|
34
|
+
if (isHeadersLike(headers)) {
|
|
35
|
+
return headers.get('authorization');
|
|
36
|
+
}
|
|
37
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
38
|
+
if (key.toLowerCase() !== 'authorization') {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
return value[0] ?? null;
|
|
43
|
+
}
|
|
44
|
+
return value ?? null;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Constant-time string compare. `timingSafeEqual` requires equal-length
|
|
50
|
+
* buffers, so we pad the shorter side to the longer side's length before
|
|
51
|
+
* comparing and then check lengths separately. The pad is on a constant-
|
|
52
|
+
* length pair so the equality check itself doesn't reveal which side was
|
|
53
|
+
* shorter via timing.
|
|
54
|
+
*/
|
|
55
|
+
function constantTimeEquals(a, b) {
|
|
56
|
+
const aBuf = Buffer.from(a, 'utf8');
|
|
57
|
+
const bBuf = Buffer.from(b, 'utf8');
|
|
58
|
+
const len = Math.max(aBuf.length, bBuf.length, 1);
|
|
59
|
+
const aPadded = Buffer.alloc(len);
|
|
60
|
+
const bPadded = Buffer.alloc(len);
|
|
61
|
+
aBuf.copy(aPadded);
|
|
62
|
+
bBuf.copy(bPadded);
|
|
63
|
+
const equal = timingSafeEqual(aPadded, bPadded);
|
|
64
|
+
return equal && aBuf.length === bBuf.length;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Reads the bearer token from the Authorization header and compares it to
|
|
68
|
+
* `ZOOMIES_API_TOKEN` (or `opts.expectedToken`) using a constant-time
|
|
69
|
+
* comparison. Throws {@link UnauthorizedError} on any failure, returns void
|
|
70
|
+
* on success.
|
|
71
|
+
*/
|
|
72
|
+
export function requireToken(headers, opts) {
|
|
73
|
+
// Re-read at each call: tests stub `process.env.ZOOMIES_API_TOKEN` via
|
|
74
|
+
// `vi.stubEnv`, and the value can also legitimately change at runtime if
|
|
75
|
+
// operators rotate the token.
|
|
76
|
+
const expected = opts?.expectedToken ?? process.env.ZOOMIES_API_TOKEN;
|
|
77
|
+
if (expected === undefined || expected === '') {
|
|
78
|
+
throw new UnauthorizedError('api token not configured');
|
|
79
|
+
}
|
|
80
|
+
const raw = readAuthorizationHeader(headers);
|
|
81
|
+
if (raw === null || raw === '') {
|
|
82
|
+
throw new UnauthorizedError('missing authorization header');
|
|
83
|
+
}
|
|
84
|
+
// Split into exactly two non-empty tokens: scheme + credential. Anything
|
|
85
|
+
// else (e.g. `Bearer` alone, `Bearer token`, `Bearer a b`) is malformed.
|
|
86
|
+
const parts = raw.split(' ');
|
|
87
|
+
if (parts.length !== 2 || parts[0] === '' || parts[1] === '') {
|
|
88
|
+
throw new UnauthorizedError('malformed authorization header');
|
|
89
|
+
}
|
|
90
|
+
const [scheme, credential] = parts;
|
|
91
|
+
if (scheme.toLowerCase() !== 'bearer') {
|
|
92
|
+
throw new UnauthorizedError('malformed authorization header');
|
|
93
|
+
}
|
|
94
|
+
if (!constantTimeEquals(credential, expected)) {
|
|
95
|
+
throw new UnauthorizedError('invalid token');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=require-token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"require-token.js","sourceRoot":"","sources":["../../../src/server/auth/require-token.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACME account key lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Every ACME interaction is signed with a long-lived account key. The CA
|
|
5
|
+
* binds issued certificates to the public half of this key, so it must
|
|
6
|
+
* persist across renewals — generating a new account key per run would
|
|
7
|
+
* leak per-host state to the CA and re-trigger ToS prompts.
|
|
8
|
+
*
|
|
9
|
+
* This module owns reading the account key from disk on warm start, and
|
|
10
|
+
* generating + persisting a fresh one on cold start. The key material is
|
|
11
|
+
* an RSA 2048 PEM produced by `acme-client`'s own crypto helper so we
|
|
12
|
+
* don't have to reimplement key encoding.
|
|
13
|
+
*/
|
|
14
|
+
export interface AcmeAccount {
|
|
15
|
+
/** PEM-encoded RSA or ECDSA private key, as required by acme.Client. */
|
|
16
|
+
accountKeyPem: string;
|
|
17
|
+
/** Contact email surfaced to the CA on account creation/update. */
|
|
18
|
+
contactEmail: string;
|
|
19
|
+
/** ACME directory URL — Let's Encrypt staging vs. production lives here. */
|
|
20
|
+
directoryUrl: string;
|
|
21
|
+
}
|
|
22
|
+
export interface LoadOrCreateAccountOptions {
|
|
23
|
+
/** Absolute path where the account key PEM is persisted across restarts. */
|
|
24
|
+
accountKeyPath: string;
|
|
25
|
+
contactEmail: string;
|
|
26
|
+
directoryUrl: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Return an {@link AcmeAccount}, creating + persisting a fresh account key
|
|
30
|
+
* on cold start. Subsequent calls with the same `accountKeyPath` round-trip
|
|
31
|
+
* the same key material byte-exact.
|
|
32
|
+
*
|
|
33
|
+
* The key file is written atomically — a crash mid-generation never leaves
|
|
34
|
+
* a truncated PEM that would deadlock the next start.
|
|
35
|
+
*/
|
|
36
|
+
export declare function loadOrCreateAccount(opts: LoadOrCreateAccountOptions): Promise<AcmeAccount>;
|
|
37
|
+
//# sourceMappingURL=acme-account.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"acme-account.d.ts","sourceRoot":"","sources":["../../../src/server/certs/acme-account.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAQH,MAAM,WAAW,WAAW;IAC1B,wEAAwE;IACxE,aAAa,EAAE,MAAM,CAAC;IACtB,mEAAmE;IACnE,YAAY,EAAE,MAAM,CAAC;IACrB,4EAA4E;IAC5E,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,0BAA0B;IACzC,4EAA4E;IAC5E,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,0BAA0B,GAAG,OAAO,CAAC,WAAW,CAAC,CAQhG"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACME account key lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Every ACME interaction is signed with a long-lived account key. The CA
|
|
5
|
+
* binds issued certificates to the public half of this key, so it must
|
|
6
|
+
* persist across renewals — generating a new account key per run would
|
|
7
|
+
* leak per-host state to the CA and re-trigger ToS prompts.
|
|
8
|
+
*
|
|
9
|
+
* This module owns reading the account key from disk on warm start, and
|
|
10
|
+
* generating + persisting a fresh one on cold start. The key material is
|
|
11
|
+
* an RSA 2048 PEM produced by `acme-client`'s own crypto helper so we
|
|
12
|
+
* don't have to reimplement key encoding.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import acme from 'acme-client';
|
|
16
|
+
import { writeAtomic } from '../reload/atomic-write.js';
|
|
17
|
+
/**
|
|
18
|
+
* Return an {@link AcmeAccount}, creating + persisting a fresh account key
|
|
19
|
+
* on cold start. Subsequent calls with the same `accountKeyPath` round-trip
|
|
20
|
+
* the same key material byte-exact.
|
|
21
|
+
*
|
|
22
|
+
* The key file is written atomically — a crash mid-generation never leaves
|
|
23
|
+
* a truncated PEM that would deadlock the next start.
|
|
24
|
+
*/
|
|
25
|
+
export async function loadOrCreateAccount(opts) {
|
|
26
|
+
const accountKeyPem = await loadOrGenerate(opts.accountKeyPath);
|
|
27
|
+
return {
|
|
28
|
+
accountKeyPem,
|
|
29
|
+
contactEmail: opts.contactEmail,
|
|
30
|
+
directoryUrl: opts.directoryUrl,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function loadOrGenerate(accountKeyPath) {
|
|
34
|
+
try {
|
|
35
|
+
return await readFile(accountKeyPath, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (err.code !== 'ENOENT') {
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// No key on disk yet — generate one and persist it atomically so a crash
|
|
43
|
+
// during generation never leaves a half-written PEM.
|
|
44
|
+
const generated = await acme.crypto.createPrivateKey();
|
|
45
|
+
const pem = generated.toString('utf8');
|
|
46
|
+
await writeAtomic(accountKeyPath, pem);
|
|
47
|
+
return pem;
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=acme-account.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"acme-account.js","sourceRoot":"","sources":["../../../src/server/certs/acme-account.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,OAAO,IAAI,MAAM,aAAa,CAAC;AAE/B,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAkBxD;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,IAAgC;IACxE,MAAM,aAAa,GAAG,MAAM,cAAc,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;IAEhE,OAAO;QACL,aAAa;QACb,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,YAAY,EAAE,IAAI,CAAC,YAAY;KAChC,CAAC;AACJ,CAAC;AAED,KAAK,UAAU,cAAc,CAAC,cAAsB;IAClD,IAAI,CAAC;QACH,OAAO,MAAM,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;IAChD,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YACrD,MAAM,GAAG,CAAC;QACZ,CAAC;IACH,CAAC;IAED,yEAAyE;IACzE,qDAAqD;IACrD,MAAM,SAAS,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,gBAAgB,EAAE,CAAC;IACvD,MAAM,GAAG,GAAG,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACvC,MAAM,WAAW,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;IACvC,OAAO,GAAG,CAAC;AACb,CAAC"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-01 challenge token persistence.
|
|
3
|
+
*
|
|
4
|
+
* ACME's HTTP-01 challenge demands that for each `token` issued by the CA we
|
|
5
|
+
* serve the matching `keyAuthorization` at
|
|
6
|
+
* `http://<domain>/.well-known/acme-challenge/<token>` over plaintext HTTP.
|
|
7
|
+
*
|
|
8
|
+
* The control plane writes those files into a state directory; NGINX is
|
|
9
|
+
* configured (separately, by the bootstrap config) to serve that directory
|
|
10
|
+
* for the `.well-known/acme-challenge/` path on port 80 for every site.
|
|
11
|
+
*
|
|
12
|
+
* This module is intentionally tiny: a token-to-file mapping with strict
|
|
13
|
+
* input validation. Anything fancier (e.g. an in-memory store, an
|
|
14
|
+
* orchestrator across nodes) is out of scope for the single-host control
|
|
15
|
+
* plane.
|
|
16
|
+
*/
|
|
17
|
+
export interface ChallengeStore {
|
|
18
|
+
/**
|
|
19
|
+
* Persist the `keyAuthorization` for `token` so NGINX can serve it.
|
|
20
|
+
* Creates {@link basePath} on first call if missing.
|
|
21
|
+
* Throws {@link ValidationError} if `token` contains characters outside
|
|
22
|
+
* the base64url alphabet — refuse the write rather than risk a path
|
|
23
|
+
* traversal.
|
|
24
|
+
*/
|
|
25
|
+
write(token: string, keyAuthorization: string): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Remove the challenge file for `token`. ENOENT is swallowed so callers
|
|
28
|
+
* can use this as cleanup in both success and failure paths without
|
|
29
|
+
* tracking which tokens they actually wrote.
|
|
30
|
+
*/
|
|
31
|
+
remove(token: string): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Absolute path to the `.well-known/acme-challenge/` directory the store
|
|
34
|
+
* writes into. Surfaced so the NGINX site renderer and the cleanup worker
|
|
35
|
+
* can read it without re-deriving the convention.
|
|
36
|
+
*/
|
|
37
|
+
basePath: string;
|
|
38
|
+
}
|
|
39
|
+
export interface CreateChallengeStoreOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Override the state directory. Defaults to `$ZOOMIES_STATE_DIR`, or
|
|
42
|
+
* `<cwd>/.zoomies` if that env var is unset (matches `db-context.ts`).
|
|
43
|
+
*/
|
|
44
|
+
stateDir?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Build a ChallengeStore bound to the resolved base directory.
|
|
48
|
+
*
|
|
49
|
+
* Pure factory: no I/O happens until the first call to {@link
|
|
50
|
+
* ChallengeStore.write}.
|
|
51
|
+
*/
|
|
52
|
+
export declare function createChallengeStore(opts?: CreateChallengeStoreOptions): ChallengeStore;
|
|
53
|
+
//# sourceMappingURL=challenge-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"challenge-store.d.ts","sourceRoot":"","sources":["../../../src/server/certs/challenge-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAgBH,MAAM,WAAW,cAAc;IAC7B;;;;;;OAMG;IACH,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9D;;;;OAIG;IACH,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAErC;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,2BAA2B;IAC1C;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAcD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,CAAC,EAAE,2BAA2B,GAAG,cAAc,CAwBvF"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-01 challenge token persistence.
|
|
3
|
+
*
|
|
4
|
+
* ACME's HTTP-01 challenge demands that for each `token` issued by the CA we
|
|
5
|
+
* serve the matching `keyAuthorization` at
|
|
6
|
+
* `http://<domain>/.well-known/acme-challenge/<token>` over plaintext HTTP.
|
|
7
|
+
*
|
|
8
|
+
* The control plane writes those files into a state directory; NGINX is
|
|
9
|
+
* configured (separately, by the bootstrap config) to serve that directory
|
|
10
|
+
* for the `.well-known/acme-challenge/` path on port 80 for every site.
|
|
11
|
+
*
|
|
12
|
+
* This module is intentionally tiny: a token-to-file mapping with strict
|
|
13
|
+
* input validation. Anything fancier (e.g. an in-memory store, an
|
|
14
|
+
* orchestrator across nodes) is out of scope for the single-host control
|
|
15
|
+
* plane.
|
|
16
|
+
*/
|
|
17
|
+
import { mkdir, unlink } from 'node:fs/promises';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { ValidationError } from '../domain/errors.js';
|
|
20
|
+
import { writeAtomic } from '../reload/atomic-write.js';
|
|
21
|
+
/**
|
|
22
|
+
* ACME token grammar — base64url alphabet, no padding, no slashes or dots.
|
|
23
|
+
* This is what `acme-client` (and the spec) hand us, but we re-validate at
|
|
24
|
+
* the boundary because the token becomes part of a filesystem path and a
|
|
25
|
+
* traversal would let a hostile CA escape the challenge directory.
|
|
26
|
+
*/
|
|
27
|
+
const TOKEN_REGEX = /^[A-Za-z0-9_-]+$/;
|
|
28
|
+
function resolveBasePath(opts) {
|
|
29
|
+
const stateDir = opts?.stateDir ?? process.env.ZOOMIES_STATE_DIR ?? join(process.cwd(), '.zoomies');
|
|
30
|
+
return join(stateDir, 'acme', '.well-known', 'acme-challenge');
|
|
31
|
+
}
|
|
32
|
+
function assertValidToken(token) {
|
|
33
|
+
if (!TOKEN_REGEX.test(token)) {
|
|
34
|
+
throw new ValidationError(`invalid ACME challenge token: ${JSON.stringify(token)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build a ChallengeStore bound to the resolved base directory.
|
|
39
|
+
*
|
|
40
|
+
* Pure factory: no I/O happens until the first call to {@link
|
|
41
|
+
* ChallengeStore.write}.
|
|
42
|
+
*/
|
|
43
|
+
export function createChallengeStore(opts) {
|
|
44
|
+
const basePath = resolveBasePath(opts);
|
|
45
|
+
return {
|
|
46
|
+
basePath,
|
|
47
|
+
async write(token, keyAuthorization) {
|
|
48
|
+
assertValidToken(token);
|
|
49
|
+
await mkdir(basePath, { recursive: true });
|
|
50
|
+
await writeAtomic(join(basePath, token), keyAuthorization);
|
|
51
|
+
},
|
|
52
|
+
async remove(token) {
|
|
53
|
+
assertValidToken(token);
|
|
54
|
+
try {
|
|
55
|
+
await unlink(join(basePath, token));
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (err.code === 'ENOENT') {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=challenge-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"challenge-store.js","sourceRoot":"","sources":["../../../src/server/certs/challenge-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAExD;;;;;GAKG;AACH,MAAM,WAAW,GAAG,kBAAkB,CAAC;AAmCvC,SAAS,eAAe,CAAC,IAAkC;IACzD,MAAM,QAAQ,GACZ,IAAI,EAAE,QAAQ,IAAI,OAAO,CAAC,GAAG,CAAC,iBAAiB,IAAI,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,UAAU,CAAC,CAAC;IACrF,OAAO,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,aAAa,EAAE,gBAAgB,CAAC,CAAC;AACjE,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAa;IACrC,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QAC7B,MAAM,IAAI,eAAe,CAAC,iCAAiC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IACtF,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAAkC;IACrE,MAAM,QAAQ,GAAG,eAAe,CAAC,IAAI,CAAC,CAAC;IAEvC,OAAO;QACL,QAAQ;QAER,KAAK,CAAC,KAAK,CAAC,KAAa,EAAE,gBAAwB;YACjD,gBAAgB,CAAC,KAAK,CAAC,CAAC;YACxB,MAAM,KAAK,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YAC3C,MAAM,WAAW,CAAC,IAAI,CAAC,QAAQ,EAAE,KAAK,CAAC,EAAE,gBAAgB,CAAC,CAAC;QAC7D,CAAC;QAED,KAAK,CAAC,MAAM,CAAC,KAAa;YACxB,gBAAgB,CAAC,KAAK,CAAC,CAAC;YACxB,IAAI,CAAC;gBACH,MAAM,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC;YACtC,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;oBACrD,OAAO;gBACT,CAAC;gBACD,MAAM,GAAG,CAAC;YACZ,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-shot ACME certificate issuance with HTTP-01.
|
|
3
|
+
*
|
|
4
|
+
* Drives the full happy-path: generate a per-domain key + CSR, ask
|
|
5
|
+
* `acme-client.auto()` to negotiate the order, hand off the resulting PEM
|
|
6
|
+
* chain + private key onto disk atomically, and extract the validity
|
|
7
|
+
* window from the leaf certificate so the caller (issue endpoint or renew
|
|
8
|
+
* loop) can persist a `Cert` row.
|
|
9
|
+
*
|
|
10
|
+
* Side effects are bracketed by rollback:
|
|
11
|
+
* - Challenge files are cleaned up by `challengeRemoveFn` (acme-client
|
|
12
|
+
* calls it in both success and failure paths in current versions).
|
|
13
|
+
* - PEM + key writes use {@link writeAtomic} and are rolled back if any
|
|
14
|
+
* later step throws — never leave a half-issued cert on disk that a
|
|
15
|
+
* reload could pick up.
|
|
16
|
+
*
|
|
17
|
+
* The acme-client surface is wrapped behind {@link AcmeClientLike} so
|
|
18
|
+
* tests can substitute a fake without depending on the library's full
|
|
19
|
+
* type surface, which has historically shifted between minor versions.
|
|
20
|
+
*/
|
|
21
|
+
import { writeAtomic } from '../reload/atomic-write.js';
|
|
22
|
+
import type { AcmeAccount } from './acme-account.js';
|
|
23
|
+
import type { ChallengeStore } from './challenge-store.js';
|
|
24
|
+
/**
|
|
25
|
+
* Minimal slice of `acme-client.Client` that the issuance flow actually
|
|
26
|
+
* touches. Keep this narrow so tests can implement it without pulling in
|
|
27
|
+
* the real client's surface.
|
|
28
|
+
*/
|
|
29
|
+
export interface AcmeClientLike {
|
|
30
|
+
/**
|
|
31
|
+
* Drive the ACME order to completion using HTTP-01. Returns the PEM
|
|
32
|
+
* chain of the issued certificate (leaf first).
|
|
33
|
+
*/
|
|
34
|
+
auto(opts: {
|
|
35
|
+
csr: Buffer;
|
|
36
|
+
email: string;
|
|
37
|
+
termsOfServiceAgreed: boolean;
|
|
38
|
+
challengeCreateFn: (authz: {
|
|
39
|
+
identifier: {
|
|
40
|
+
value: string;
|
|
41
|
+
};
|
|
42
|
+
}, challenge: {
|
|
43
|
+
token: string;
|
|
44
|
+
type: string;
|
|
45
|
+
}, keyAuthorization: string) => Promise<void>;
|
|
46
|
+
challengeRemoveFn: (authz: {
|
|
47
|
+
identifier: {
|
|
48
|
+
value: string;
|
|
49
|
+
};
|
|
50
|
+
}, challenge: {
|
|
51
|
+
token: string;
|
|
52
|
+
type: string;
|
|
53
|
+
}, keyAuthorization: string) => Promise<void>;
|
|
54
|
+
}): Promise<string>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Validity window extracted from a PEM-encoded leaf certificate. Both
|
|
58
|
+
* fields must be `Date` instances; the caller serialises to ISO-8601.
|
|
59
|
+
*/
|
|
60
|
+
export interface CertificateValidity {
|
|
61
|
+
notBefore: Date;
|
|
62
|
+
notAfter: Date;
|
|
63
|
+
}
|
|
64
|
+
export interface IssueDeps {
|
|
65
|
+
/**
|
|
66
|
+
* Factory for the ACME client. Default wraps `acme.Client` from
|
|
67
|
+
* `acme-client`; tests substitute a fake that returns a canned PEM.
|
|
68
|
+
*/
|
|
69
|
+
createClient: (account: AcmeAccount) => AcmeClientLike;
|
|
70
|
+
/**
|
|
71
|
+
* Atomic file writer. Defaults to the production {@link writeAtomic};
|
|
72
|
+
* tests rarely override but the seam is here for parity with the rest
|
|
73
|
+
* of the codebase.
|
|
74
|
+
*/
|
|
75
|
+
writeFile: typeof writeAtomic;
|
|
76
|
+
/**
|
|
77
|
+
* Parse the validity window out of a leaf certificate PEM. Defaults to
|
|
78
|
+
* `acme.crypto.readCertificateInfo`. Injected so tests can avoid having
|
|
79
|
+
* to construct a fully-valid x509 PEM with non-degenerate dates.
|
|
80
|
+
*/
|
|
81
|
+
readCertificateValidity: (pemChain: string) => CertificateValidity;
|
|
82
|
+
}
|
|
83
|
+
export interface IssueOptions {
|
|
84
|
+
domain: string;
|
|
85
|
+
account: AcmeAccount;
|
|
86
|
+
challengeStore: ChallengeStore;
|
|
87
|
+
/** Directory to write `<domain>.pem` and `<domain>.key` into. */
|
|
88
|
+
certDir: string;
|
|
89
|
+
deps?: Partial<IssueDeps>;
|
|
90
|
+
}
|
|
91
|
+
export interface IssueResult {
|
|
92
|
+
domain: string;
|
|
93
|
+
pemPath: string;
|
|
94
|
+
keyPath: string;
|
|
95
|
+
/** ISO-8601 — directly insertable into the `certs` table. */
|
|
96
|
+
notBefore: string;
|
|
97
|
+
/** ISO-8601 — directly insertable into the `certs` table. */
|
|
98
|
+
notAfter: string;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Issue a single certificate via HTTP-01. Returns the data needed to
|
|
102
|
+
* insert (or update) a `Cert` row — the caller is responsible for that
|
|
103
|
+
* persistence step.
|
|
104
|
+
*/
|
|
105
|
+
export declare function issueCertificate(opts: IssueOptions): Promise<IssueResult>;
|
|
106
|
+
//# sourceMappingURL=issue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"issue.d.ts","sourceRoot":"","sources":["../../../src/server/certs/issue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAQH,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AACxD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,IAAI,CAAC,IAAI,EAAE;QACT,GAAG,EAAE,MAAM,CAAC;QACZ,KAAK,EAAE,MAAM,CAAC;QACd,oBAAoB,EAAE,OAAO,CAAC;QAC9B,iBAAiB,EAAE,CACjB,KAAK,EAAE;YAAE,UAAU,EAAE;gBAAE,KAAK,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,EACxC,SAAS,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAE,EAC1C,gBAAgB,EAAE,MAAM,KACrB,OAAO,CAAC,IAAI,CAAC,CAAC;QACnB,iBAAiB,EAAE,CACjB,KAAK,EAAE;YAAE,UAAU,EAAE;gBAAE,KAAK,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,EACxC,SAAS,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAE,EAC1C,gBAAgB,EAAE,MAAM,KACrB,OAAO,CAAC,IAAI,CAAC,CAAC;KACpB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACrB;AAED;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,IAAI,CAAC;IAChB,QAAQ,EAAE,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,SAAS;IACxB;;;OAGG;IACH,YAAY,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,cAAc,CAAC;IACvD;;;;OAIG;IACH,SAAS,EAAE,OAAO,WAAW,CAAC;IAC9B;;;;OAIG;IACH,uBAAuB,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,mBAAmB,CAAC;CACpE;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,WAAW,CAAC;IACrB,cAAc,EAAE,cAAc,CAAC;IAC/B,iEAAiE;IACjE,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;CAC3B;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,SAAS,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,QAAQ,EAAE,MAAM,CAAC;CAClB;AA0BD;;;;GAIG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CA2D/E"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-shot ACME certificate issuance with HTTP-01.
|
|
3
|
+
*
|
|
4
|
+
* Drives the full happy-path: generate a per-domain key + CSR, ask
|
|
5
|
+
* `acme-client.auto()` to negotiate the order, hand off the resulting PEM
|
|
6
|
+
* chain + private key onto disk atomically, and extract the validity
|
|
7
|
+
* window from the leaf certificate so the caller (issue endpoint or renew
|
|
8
|
+
* loop) can persist a `Cert` row.
|
|
9
|
+
*
|
|
10
|
+
* Side effects are bracketed by rollback:
|
|
11
|
+
* - Challenge files are cleaned up by `challengeRemoveFn` (acme-client
|
|
12
|
+
* calls it in both success and failure paths in current versions).
|
|
13
|
+
* - PEM + key writes use {@link writeAtomic} and are rolled back if any
|
|
14
|
+
* later step throws — never leave a half-issued cert on disk that a
|
|
15
|
+
* reload could pick up.
|
|
16
|
+
*
|
|
17
|
+
* The acme-client surface is wrapped behind {@link AcmeClientLike} so
|
|
18
|
+
* tests can substitute a fake without depending on the library's full
|
|
19
|
+
* type surface, which has historically shifted between minor versions.
|
|
20
|
+
*/
|
|
21
|
+
import { chmod } from 'node:fs/promises';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import acme from 'acme-client';
|
|
24
|
+
import { writeAtomic } from '../reload/atomic-write.js';
|
|
25
|
+
/**
|
|
26
|
+
* Default factory wrapping `acme.Client`. The narrow {@link AcmeClientLike}
|
|
27
|
+
* type means we cast at the boundary; the cast is safe because the real
|
|
28
|
+
* `auto` signature is a superset of our minimal interface.
|
|
29
|
+
*/
|
|
30
|
+
function defaultCreateClient(account) {
|
|
31
|
+
const client = new acme.Client({
|
|
32
|
+
directoryUrl: account.directoryUrl,
|
|
33
|
+
accountKey: account.accountKeyPem,
|
|
34
|
+
});
|
|
35
|
+
return client;
|
|
36
|
+
}
|
|
37
|
+
function defaultReadCertificateValidity(pemChain) {
|
|
38
|
+
const info = acme.crypto.readCertificateInfo(pemChain);
|
|
39
|
+
return { notBefore: info.notBefore, notAfter: info.notAfter };
|
|
40
|
+
}
|
|
41
|
+
const defaultDeps = {
|
|
42
|
+
createClient: defaultCreateClient,
|
|
43
|
+
writeFile: writeAtomic,
|
|
44
|
+
readCertificateValidity: defaultReadCertificateValidity,
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Issue a single certificate via HTTP-01. Returns the data needed to
|
|
48
|
+
* insert (or update) a `Cert` row — the caller is responsible for that
|
|
49
|
+
* persistence step.
|
|
50
|
+
*/
|
|
51
|
+
export async function issueCertificate(opts) {
|
|
52
|
+
const deps = { ...defaultDeps, ...opts.deps };
|
|
53
|
+
const { domain, account, challengeStore, certDir } = opts;
|
|
54
|
+
// 1. Per-domain key + CSR. acme-client returns a tuple of [keyPem, csrPem]
|
|
55
|
+
// and we hand the CSR (as a Buffer) into `auto`.
|
|
56
|
+
const [keyPem, csrBuffer] = await acme.crypto.createCsr({
|
|
57
|
+
commonName: domain,
|
|
58
|
+
altNames: [domain],
|
|
59
|
+
});
|
|
60
|
+
// 2. Drive the order. challengeCreateFn / challengeRemoveFn mediate the
|
|
61
|
+
// HTTP-01 dance via the store. acme-client invokes Remove on success
|
|
62
|
+
// AND failure paths, so we don't need to track tokens ourselves.
|
|
63
|
+
const client = deps.createClient(account);
|
|
64
|
+
const pemChain = await client.auto({
|
|
65
|
+
csr: csrBuffer,
|
|
66
|
+
email: account.contactEmail,
|
|
67
|
+
termsOfServiceAgreed: true,
|
|
68
|
+
challengeCreateFn: async (_authz, challenge, keyAuthorization) => {
|
|
69
|
+
await challengeStore.write(challenge.token, keyAuthorization);
|
|
70
|
+
},
|
|
71
|
+
challengeRemoveFn: async (_authz, challenge) => {
|
|
72
|
+
await challengeStore.remove(challenge.token);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
// 3. Validity window from the leaf cert. The dep returns `Date` instances;
|
|
76
|
+
// we serialise to ISO-8601 to match the rest of the schema.
|
|
77
|
+
const validity = deps.readCertificateValidity(pemChain);
|
|
78
|
+
const notBefore = validity.notBefore.toISOString();
|
|
79
|
+
const notAfter = validity.notAfter.toISOString();
|
|
80
|
+
// 4. Atomically write the cert + key. If either step throws, roll the
|
|
81
|
+
// other back so an issuance failure never leaves a half-installed
|
|
82
|
+
// pair on disk.
|
|
83
|
+
const pemPath = join(certDir, `${domain}.pem`);
|
|
84
|
+
const keyPath = join(certDir, `${domain}.key`);
|
|
85
|
+
const rollbacks = [];
|
|
86
|
+
try {
|
|
87
|
+
rollbacks.push(await deps.writeFile(pemPath, pemChain));
|
|
88
|
+
rollbacks.push(await deps.writeFile(keyPath, keyPem.toString('utf8')));
|
|
89
|
+
// Private key must not be world-readable. The atomic write applies its
|
|
90
|
+
// default mode; tighten via chmod after the rename so the tightening
|
|
91
|
+
// is atomic relative to the file's final identity.
|
|
92
|
+
await chmod(keyPath, 0o600);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
for (const handle of rollbacks.reverse()) {
|
|
96
|
+
try {
|
|
97
|
+
await handle.restore();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Best-effort rollback — don't mask the original error.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
return { domain, pemPath, keyPath, notBefore, notAfter };
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=issue.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"issue.js","sourceRoot":"","sources":["../../../src/server/certs/issue.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renew an existing `Cert` row by re-issuing for the same domain.
|
|
3
|
+
*
|
|
4
|
+
* The renew flow is a thin wrapper around {@link issueCertificate}:
|
|
5
|
+
*
|
|
6
|
+
* 1. Issue a new chain + key for `cert.domain`. The atomic writes in
|
|
7
|
+
* `issueCertificate` overwrite the existing PEM/key files in place,
|
|
8
|
+
* so the on-disk paths usually don't change.
|
|
9
|
+
* 2. Persist the fresh validity window to the DB. The repository bumps
|
|
10
|
+
* `updatedAt` automatically.
|
|
11
|
+
*
|
|
12
|
+
* Failure modes:
|
|
13
|
+
* - If `issueCertificate` throws, the DB row is untouched (we update
|
|
14
|
+
* only on success). The atomic writes in `issueCertificate` roll back
|
|
15
|
+
* their own partial state.
|
|
16
|
+
* - If the DB update throws, the new files are already on disk — that's
|
|
17
|
+
* an acceptable inconsistency for now because the next renew attempt
|
|
18
|
+
* will overwrite them. A future enhancement can swap that out for a
|
|
19
|
+
* transactional pair.
|
|
20
|
+
*/
|
|
21
|
+
import type { Cert } from '../domain/cert.js';
|
|
22
|
+
import type { CertRepository } from '../repositories/cert-repository.js';
|
|
23
|
+
import { type IssueOptions } from './issue.js';
|
|
24
|
+
export interface RenewOptions extends IssueOptions {
|
|
25
|
+
/** The existing row whose validity window we're replacing. */
|
|
26
|
+
cert: Cert;
|
|
27
|
+
certRepo: CertRepository;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Re-issue the certificate for `cert.domain` and persist the new validity
|
|
31
|
+
* window onto the existing row. Returns the updated `Cert`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function renewCertificate(opts: RenewOptions): Promise<Cert>;
|
|
34
|
+
//# sourceMappingURL=renew.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"renew.d.ts","sourceRoot":"","sources":["../../../src/server/certs/renew.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AACzE,OAAO,EAAoB,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAEjE,MAAM,WAAW,YAAa,SAAQ,YAAY;IAChD,8DAA8D;IAC9D,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,cAAc,CAAC;CAC1B;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAWxE"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renew an existing `Cert` row by re-issuing for the same domain.
|
|
3
|
+
*
|
|
4
|
+
* The renew flow is a thin wrapper around {@link issueCertificate}:
|
|
5
|
+
*
|
|
6
|
+
* 1. Issue a new chain + key for `cert.domain`. The atomic writes in
|
|
7
|
+
* `issueCertificate` overwrite the existing PEM/key files in place,
|
|
8
|
+
* so the on-disk paths usually don't change.
|
|
9
|
+
* 2. Persist the fresh validity window to the DB. The repository bumps
|
|
10
|
+
* `updatedAt` automatically.
|
|
11
|
+
*
|
|
12
|
+
* Failure modes:
|
|
13
|
+
* - If `issueCertificate` throws, the DB row is untouched (we update
|
|
14
|
+
* only on success). The atomic writes in `issueCertificate` roll back
|
|
15
|
+
* their own partial state.
|
|
16
|
+
* - If the DB update throws, the new files are already on disk — that's
|
|
17
|
+
* an acceptable inconsistency for now because the next renew attempt
|
|
18
|
+
* will overwrite them. A future enhancement can swap that out for a
|
|
19
|
+
* transactional pair.
|
|
20
|
+
*/
|
|
21
|
+
import { issueCertificate } from './issue.js';
|
|
22
|
+
/**
|
|
23
|
+
* Re-issue the certificate for `cert.domain` and persist the new validity
|
|
24
|
+
* window onto the existing row. Returns the updated `Cert`.
|
|
25
|
+
*/
|
|
26
|
+
export async function renewCertificate(opts) {
|
|
27
|
+
const { cert, certRepo, ...issueOpts } = opts;
|
|
28
|
+
const result = await issueCertificate(issueOpts);
|
|
29
|
+
return certRepo.update(cert.id, {
|
|
30
|
+
pemPath: result.pemPath,
|
|
31
|
+
keyPath: result.keyPath,
|
|
32
|
+
notBefore: result.notBefore,
|
|
33
|
+
notAfter: result.notAfter,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=renew.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"renew.js","sourceRoot":"","sources":["../../../src/server/certs/renew.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,EAAE,gBAAgB,EAAqB,MAAM,YAAY,CAAC;AAQjE;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,IAAkB;IACvD,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,SAAS,EAAE,GAAG,IAAI,CAAC;IAE9C,MAAM,MAAM,GAAG,MAAM,gBAAgB,CAAC,SAAS,CAAC,CAAC;IAEjD,OAAO,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE;QAC9B,OAAO,EAAE,MAAM,CAAC,OAAO;QACvB,OAAO,EAAE,MAAM,CAAC,OAAO;QACvB,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,QAAQ,EAAE,MAAM,CAAC,QAAQ;KAC1B,CAAC,CAAC;AACL,CAAC"}
|