@peerbit/server 6.0.42 → 7.0.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.
Files changed (84) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +161 -352
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/client.d.ts +1 -1
  5. package/dist/src/client.d.ts.map +1 -1
  6. package/dist/src/client.js +2 -5
  7. package/dist/src/client.js.map +1 -1
  8. package/dist/src/docker.d.ts +70 -1
  9. package/dist/src/docker.d.ts.map +1 -1
  10. package/dist/src/docker.js +559 -14
  11. package/dist/src/docker.js.map +1 -1
  12. package/dist/src/domain-lease.d.ts +126 -0
  13. package/dist/src/domain-lease.d.ts.map +1 -0
  14. package/dist/src/domain-lease.js +800 -0
  15. package/dist/src/domain-lease.js.map +1 -0
  16. package/dist/src/domain.d.ts +50 -2
  17. package/dist/src/domain.d.ts.map +1 -1
  18. package/dist/src/domain.js +484 -67
  19. package/dist/src/domain.js.map +1 -1
  20. package/dist/src/hetzner.d.ts +4 -21
  21. package/dist/src/hetzner.d.ts.map +1 -1
  22. package/dist/src/hetzner.js +4 -116
  23. package/dist/src/hetzner.js.map +1 -1
  24. package/dist/src/nginx-template.conf +19 -1
  25. package/dist/src/remotes.browser.d.ts +4 -1
  26. package/dist/src/remotes.browser.d.ts.map +1 -1
  27. package/dist/src/remotes.browser.js +1 -2
  28. package/dist/src/remotes.browser.js.map +1 -1
  29. package/dist/src/remotes.d.ts +2 -1
  30. package/dist/src/remotes.d.ts.map +1 -1
  31. package/dist/src/remotes.js +1 -0
  32. package/dist/src/remotes.js.map +1 -1
  33. package/dist/src/server.d.ts.map +1 -1
  34. package/dist/src/server.js +3 -5
  35. package/dist/src/server.js.map +1 -1
  36. package/dist/test/api.spec.js +2 -1
  37. package/dist/test/api.spec.js.map +1 -1
  38. package/dist/test/cli.spec.js +48 -1
  39. package/dist/test/cli.spec.js.map +1 -1
  40. package/dist/test/docker.spec.d.ts +2 -0
  41. package/dist/test/docker.spec.d.ts.map +1 -0
  42. package/dist/test/docker.spec.js +822 -0
  43. package/dist/test/docker.spec.js.map +1 -0
  44. package/dist/test/domain-lease.spec.d.ts +2 -0
  45. package/dist/test/domain-lease.spec.d.ts.map +1 -0
  46. package/dist/test/domain-lease.spec.js +530 -0
  47. package/dist/test/domain-lease.spec.js.map +1 -0
  48. package/dist/test/domain.integration.spec.js +358 -75
  49. package/dist/test/domain.integration.spec.js.map +1 -1
  50. package/dist/test/remotes.spec.d.ts +2 -0
  51. package/dist/test/remotes.spec.d.ts.map +1 -0
  52. package/dist/test/remotes.spec.js +15 -0
  53. package/dist/test/remotes.spec.js.map +1 -0
  54. package/dist/tsconfig.tsbuildinfo +1 -1
  55. package/dist/ui/assets/index-CjZwCewh.js +10 -0
  56. package/dist/ui/assets/index-e3g42MbC.js +9 -0
  57. package/dist/ui/index.html +1 -1
  58. package/package.json +14 -19
  59. package/src/cli.ts +204 -408
  60. package/src/client.ts +2 -6
  61. package/src/docker.ts +841 -20
  62. package/src/domain-lease.ts +1118 -0
  63. package/src/domain.ts +650 -90
  64. package/src/hetzner.ts +4 -184
  65. package/src/nginx-template.conf +19 -1
  66. package/src/remotes.browser.ts +7 -1
  67. package/src/remotes.ts +6 -1
  68. package/src/server.ts +3 -5
  69. package/dist/src/aws.browser.d.ts +0 -2
  70. package/dist/src/aws.browser.d.ts.map +0 -1
  71. package/dist/src/aws.browser.js +0 -3
  72. package/dist/src/aws.browser.js.map +0 -1
  73. package/dist/src/aws.d.ts +0 -30
  74. package/dist/src/aws.d.ts.map +0 -1
  75. package/dist/src/aws.js +0 -240
  76. package/dist/src/aws.js.map +0 -1
  77. package/dist/test/launch.spec.d.ts +0 -2
  78. package/dist/test/launch.spec.d.ts.map +0 -1
  79. package/dist/test/launch.spec.js +0 -23
  80. package/dist/test/launch.spec.js.map +0 -1
  81. package/dist/ui/assets/index-BBTKOM0z.js +0 -6
  82. package/dist/ui/assets/index-DVtj_GOf.js +0 -13
  83. package/src/aws.browser.ts +0 -1
  84. package/src/aws.ts +0 -327
package/src/domain.ts CHANGED
@@ -1,8 +1,23 @@
1
1
  /* eslint-disable no-console */
2
- import { waitFor } from "@peerbit/time";
2
+ import { delay } from "@peerbit/time";
3
+ import type { DockerContainerInspection } from "./docker.js";
3
4
 
4
5
  const isNode = typeof process !== "undefined" && process.versions?.node != null;
5
6
 
7
+ export const CERTBOT_IMAGE =
8
+ "jonasal/nginx-certbot:6.2.0-nginx1.31.2@sha256:59c217749aa27effc5109249902e1f5cdbfdfa26721d7dbd4e7162380a07c2cc";
9
+ export const CERTBOT_MANAGED_LABEL = "org.peerbit.managed";
10
+ export const CERTBOT_MANAGED_LABEL_VALUE = "nginx-certbot";
11
+
12
+ const CERTBOT_MOUNT_DESTINATIONS = [
13
+ "/etc/letsencrypt",
14
+ "/etc/nginx/user_conf.d",
15
+ "/usr/share/nginx/html",
16
+ ] as const;
17
+ const DOMAIN_STATE_FOLDER = ".peerbit-domain";
18
+ const DOMAIN_GENERATIONS_FOLDER = "generations";
19
+ const ACTIVE_DOMAIN_GENERATION_FILE = "active";
20
+
6
21
  const getConfigFileTemplate = async (): Promise<string> => {
7
22
  const url = await import("url");
8
23
  const filename = url.fileURLToPath(import.meta.url);
@@ -15,19 +30,9 @@ const getConfigFileTemplate = async (): Promise<string> => {
15
30
  return file;
16
31
  };
17
32
 
18
- const getNginxFolderPath = async () => {
19
- const { exec } = await import("child_process");
20
- const pwd: string = await new Promise((resolve, reject) => {
21
- exec("pwd", (error, stdout, stderr) => {
22
- if (error || stderr) {
23
- reject("Failed to get current directory");
24
- }
25
- resolve(stdout.trimEnd());
26
- });
27
- });
28
-
33
+ const getLegacyNginxFolderPath = async () => {
29
34
  const path = await import("path");
30
- const nginxConfigPath = path.join(pwd, "nginx");
35
+ const nginxConfigPath = path.join(process.cwd(), "nginx");
31
36
  return nginxConfigPath;
32
37
  };
33
38
 
@@ -48,25 +53,40 @@ const createConfig = async (
48
53
  file = file.replaceAll("%DOMAIN%", domain);
49
54
 
50
55
  const fs = await import("fs");
51
-
52
- fs.mkdir(outputPath, { recursive: true }, (err) => {
53
- if (err) throw err;
54
- });
55
-
56
- await waitFor(() => fs.existsSync(outputPath));
57
-
56
+ fs.mkdirSync(outputPath, { recursive: true });
58
57
  fs.writeFileSync(await getNginxConfigPath(outputPath), file);
59
58
  return { domain };
60
59
  };
61
60
 
62
61
  export const loadConfig = async () => {
63
- const configFilePath = await getNginxConfigPath(await getNginxFolderPath());
64
62
  const fs = await import("fs");
65
- if (!fs.existsSync(configFilePath)) {
66
- return undefined;
63
+ const path = await import("path");
64
+ const stateRoot = path.join(process.cwd(), DOMAIN_STATE_FOLDER);
65
+ const activeGenerationPath = path.join(
66
+ stateRoot,
67
+ ACTIVE_DOMAIN_GENERATION_FILE,
68
+ );
69
+ if (fs.existsSync(activeGenerationPath)) {
70
+ const generation = normalizeDomainGenerationName(
71
+ fs.readFileSync(activeGenerationPath, "utf8").trim(),
72
+ );
73
+ const activeConfigPath = await getNginxConfigPath(
74
+ path.join(stateRoot, DOMAIN_GENERATIONS_FOLDER, generation, "nginx"),
75
+ );
76
+ if (!fs.existsSync(activeConfigPath)) {
77
+ throw new Error(
78
+ `Active domain configuration ${generation} is missing ${activeConfigPath}; repair or remove ${activeGenerationPath}`,
79
+ );
80
+ }
81
+ return fs.readFileSync(activeConfigPath, "utf-8");
67
82
  }
68
- const file = fs.readFileSync(configFilePath, "utf-8");
69
- return file;
83
+
84
+ const legacyConfigPath = await getNginxConfigPath(
85
+ await getLegacyNginxFolderPath(),
86
+ );
87
+ return fs.existsSync(legacyConfigPath)
88
+ ? fs.readFileSync(legacyConfigPath, "utf-8")
89
+ : undefined;
70
90
  };
71
91
 
72
92
  export const getDomainFromConfig = async (config: string) => {
@@ -75,6 +95,139 @@ export const getDomainFromConfig = async (config: string) => {
75
95
  const domain = match?.[1];
76
96
  return domain === "%DOMAIN%" ? undefined : domain;
77
97
  };
98
+
99
+ export const normalizeDomain = (domain: string): string => {
100
+ const normalized = domain.trim().toLowerCase().replace(/\.$/, "");
101
+ const labels = normalized.split(".");
102
+ const isValidLabel = (label: string) =>
103
+ label.length > 0 &&
104
+ label.length <= 63 &&
105
+ /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label);
106
+
107
+ if (
108
+ normalized.length > 253 ||
109
+ labels.length < 2 ||
110
+ !labels.every(isValidLabel)
111
+ ) {
112
+ throw new Error(`Invalid domain: ${domain}`);
113
+ }
114
+
115
+ return normalized;
116
+ };
117
+
118
+ type DomainReadinessResponse = {
119
+ data: unknown;
120
+ status: number;
121
+ };
122
+
123
+ type DomainReadinessRequest = (
124
+ url: string,
125
+ options: { signal: AbortSignal; timeoutMs: number },
126
+ ) => Promise<DomainReadinessResponse>;
127
+
128
+ export const isPeerbitDomainReadyResponse = (
129
+ response: DomainReadinessResponse,
130
+ ) =>
131
+ response.status === 200 &&
132
+ typeof response.data === "string" &&
133
+ response.data.includes("<title>Peerbit</title>") &&
134
+ response.data.includes('content="Peerbit node"');
135
+
136
+ export const isPeerbitDomainChallengeResponse = (
137
+ response: DomainReadinessResponse,
138
+ challengeToken: string,
139
+ ) => response.status === 200 && response.data === challengeToken;
140
+
141
+ export const waitForPeerbitDomain = async (
142
+ domain: string,
143
+ options: {
144
+ challengeToken?: string;
145
+ delayIntervalMs?: number;
146
+ request?: DomainReadinessRequest;
147
+ requestTimeoutMs?: number;
148
+ timeoutMs?: number;
149
+ } = {},
150
+ ) => {
151
+ domain = normalizeDomain(domain);
152
+ const challengeToken = options.challengeToken;
153
+ if (challengeToken && !/^[a-f0-9]{64}$/.test(challengeToken)) {
154
+ throw new Error("Domain readiness challenge token is invalid");
155
+ }
156
+ const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
157
+ const delayIntervalMs = options.delayIntervalMs ?? 5000;
158
+ const requestTimeoutMs = options.requestTimeoutMs ?? 5000;
159
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
160
+ throw new Error("Domain readiness timeout must be positive");
161
+ }
162
+ if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
163
+ throw new Error("Domain readiness request timeout must be positive");
164
+ }
165
+ if (!Number.isFinite(delayIntervalMs) || delayIntervalMs < 0) {
166
+ throw new Error("Domain readiness delay must not be negative");
167
+ }
168
+ let request = options.request;
169
+ if (!request) {
170
+ const { default: axios } = await import("axios");
171
+ request = (url, requestOptions) =>
172
+ axios.get(url, {
173
+ headers: {
174
+ "Cache-Control": "no-cache, no-store",
175
+ Pragma: "no-cache",
176
+ },
177
+ maxRedirects: 0,
178
+ responseType: "text",
179
+ signal: requestOptions.signal,
180
+ timeout: requestOptions.timeoutMs,
181
+ validateStatus: () => true,
182
+ });
183
+ }
184
+
185
+ const deadline = Date.now() + timeoutMs;
186
+ let attempt = 0;
187
+ while (Date.now() < deadline) {
188
+ const remainingMs = deadline - Date.now();
189
+ const attemptTimeoutMs = Math.min(requestTimeoutMs, remainingMs);
190
+ const controller = new AbortController();
191
+ let timer: ReturnType<typeof setTimeout> | undefined;
192
+ try {
193
+ attempt += 1;
194
+ const requestUrl = challengeToken
195
+ ? `https://${domain}/.well-known/peerbit-generation/${challengeToken}?attempt=${attempt}`
196
+ : `https://${domain}`;
197
+ const response = await Promise.race([
198
+ request(requestUrl, {
199
+ signal: controller.signal,
200
+ timeoutMs: attemptTimeoutMs,
201
+ }),
202
+ new Promise<never>((_resolve, reject) => {
203
+ timer = setTimeout(() => {
204
+ controller.abort();
205
+ reject(new Error("Domain readiness request timed out"));
206
+ }, attemptTimeoutMs);
207
+ }),
208
+ ]);
209
+ if (
210
+ challengeToken
211
+ ? isPeerbitDomainChallengeResponse(response, challengeToken)
212
+ : isPeerbitDomainReadyResponse(response)
213
+ ) {
214
+ return;
215
+ }
216
+ } catch {
217
+ // DNS, TLS, HTTP, and per-request timeout failures are retried until the
218
+ // overall deadline. The old container remains available for rollback.
219
+ } finally {
220
+ if (timer) clearTimeout(timer);
221
+ controller.abort();
222
+ }
223
+
224
+ const sleepMs = Math.min(delayIntervalMs, deadline - Date.now());
225
+ if (sleepMs > 0) await delay(sleepMs);
226
+ }
227
+
228
+ throw new Error(`Timed out waiting for Peerbit at https://${domain}`);
229
+ };
230
+
78
231
  const getUIPath = async (): Promise<string> => {
79
232
  const url = await import("url");
80
233
  const path = await import("path");
@@ -93,34 +246,6 @@ const getUIPath = async (): Promise<string> => {
93
246
  throw new Error("Failed to find UI path");
94
247
  }
95
248
  };
96
- export const getMyIp = async (): Promise<string> => {
97
- const { exec } = await import("child_process");
98
- const ipv4: string = await new Promise((resolve, reject) => {
99
- exec(
100
- "dig @resolver4.opendns.com myip.opendns.com +short",
101
- (error, stdout, stderr) => {
102
- if (error || stderr) {
103
- reject("DNS lookup failed");
104
- }
105
- resolve(stdout.trimEnd());
106
- },
107
- );
108
- });
109
- return ipv4;
110
- };
111
-
112
- export const createTestDomain = async () => {
113
- const { default: axios } = await import("axios");
114
- const domain: string = (
115
- await axios.post(
116
- "https://bfbbnhwpfj2ptcmurz6lit4xlu0vjajw.lambda-url.us-east-1.on.aws",
117
- await getMyIp(),
118
- { headers: { "Content-Type": "application/json" } },
119
- )
120
- ).data.domain;
121
- return domain;
122
- };
123
-
124
249
  const validateEmail = (email: any) => {
125
250
  return String(email)
126
251
  .toLowerCase()
@@ -129,61 +254,496 @@ const validateEmail = (email: any) => {
129
254
  );
130
255
  };
131
256
 
257
+ export const normalizeDockerContainerName = (name: string): string => {
258
+ const normalized = name.trim();
259
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(normalized)) {
260
+ throw new Error(`Invalid Docker container name: ${name}`);
261
+ }
262
+ return normalized;
263
+ };
264
+
265
+ export const normalizeDomainGenerationName = (name: string): string => {
266
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9-]{0,127}$/.test(name)) {
267
+ throw new Error(`Invalid domain configuration generation: ${name}`);
268
+ }
269
+ return name;
270
+ };
271
+
272
+ export const activateDomainGeneration = async (
273
+ stateRoot: string,
274
+ generation: string,
275
+ ) => {
276
+ generation = normalizeDomainGenerationName(generation);
277
+ const fs = await import("fs");
278
+ const path = await import("path");
279
+ fs.mkdirSync(stateRoot, { recursive: true });
280
+ const activePath = path.join(stateRoot, ACTIVE_DOMAIN_GENERATION_FILE);
281
+ const stagingPath = `${activePath}.peerbit-${process.pid}-${Date.now()}`;
282
+ try {
283
+ fs.writeFileSync(stagingPath, generation, "utf8");
284
+ fs.renameSync(stagingPath, activePath);
285
+ } finally {
286
+ fs.rmSync(stagingPath, { force: true });
287
+ }
288
+ };
289
+
290
+ export const pruneDomainGenerations = async (
291
+ stateRoot: string,
292
+ activeGeneration: string,
293
+ keepPrevious = 2,
294
+ ) => {
295
+ activeGeneration = normalizeDomainGenerationName(activeGeneration);
296
+ if (!Number.isInteger(keepPrevious) || keepPrevious < 0) {
297
+ throw new Error(
298
+ "Domain generation retention must be a non-negative integer",
299
+ );
300
+ }
301
+ const fs = await import("fs");
302
+ const path = await import("path");
303
+ const generationsRoot = path.join(stateRoot, DOMAIN_GENERATIONS_FOLDER);
304
+ if (!fs.existsSync(generationsRoot)) return;
305
+
306
+ const generations = fs
307
+ .readdirSync(generationsRoot, { withFileTypes: true })
308
+ .filter((entry) => entry.isDirectory())
309
+ .map((entry) => entry.name)
310
+ .filter((name) => {
311
+ try {
312
+ normalizeDomainGenerationName(name);
313
+ return true;
314
+ } catch {
315
+ return false;
316
+ }
317
+ })
318
+ .sort()
319
+ .reverse();
320
+ const retained = new Set([
321
+ activeGeneration,
322
+ ...generations
323
+ .filter((generation) => generation !== activeGeneration)
324
+ .slice(0, keepPrevious),
325
+ ]);
326
+ for (const generation of generations) {
327
+ if (!retained.has(generation)) {
328
+ fs.rmSync(path.join(generationsRoot, generation), {
329
+ recursive: true,
330
+ force: true,
331
+ });
332
+ }
333
+ }
334
+ };
335
+
336
+ export const buildCertbotContainerArguments = (properties: {
337
+ containerName: string;
338
+ email: string;
339
+ nginxConfigPath: string;
340
+ secretsPath: string;
341
+ uiPath: string;
342
+ staging?: boolean;
343
+ }): string[] => [
344
+ "run",
345
+ "--name",
346
+ normalizeDockerContainerName(properties.containerName),
347
+ "-d",
348
+ "--net=host",
349
+ "--restart",
350
+ "unless-stopped",
351
+ "--label",
352
+ `${CERTBOT_MANAGED_LABEL}=${CERTBOT_MANAGED_LABEL_VALUE}`,
353
+ "--env",
354
+ `CERTBOT_EMAIL=${properties.email}`,
355
+ ...(properties.staging ? ["--env", "STAGING=1"] : []),
356
+ "-v",
357
+ `${properties.secretsPath}:/etc/letsencrypt`,
358
+ "-v",
359
+ `${properties.nginxConfigPath}:/etc/nginx/user_conf.d:ro`,
360
+ "-v",
361
+ `${properties.uiPath}:/usr/share/nginx/html:ro`,
362
+ CERTBOT_IMAGE,
363
+ ];
364
+
365
+ export const isPeerbitCertbotContainer = (
366
+ inspection: DockerContainerInspection,
367
+ ): boolean => {
368
+ const destinations = new Set(
369
+ inspection.Mounts?.map((mount) => mount.Destination).filter(Boolean),
370
+ );
371
+ const hasExpectedMounts = CERTBOT_MOUNT_DESTINATIONS.every((destination) =>
372
+ destinations.has(destination),
373
+ );
374
+ if (!hasExpectedMounts) return false;
375
+
376
+ if (
377
+ inspection.Config?.Labels?.[CERTBOT_MANAGED_LABEL] ===
378
+ CERTBOT_MANAGED_LABEL_VALUE
379
+ ) {
380
+ return true;
381
+ }
382
+
383
+ return (
384
+ /^jonasal\/nginx-certbot(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/.test(
385
+ inspection.Config?.Image || "",
386
+ ) &&
387
+ inspection.Config?.Env?.some((value) =>
388
+ value.startsWith("CERTBOT_EMAIL="),
389
+ ) === true
390
+ );
391
+ };
392
+
393
+ type MountedDomainGeneration = {
394
+ configPath: string;
395
+ generation: string;
396
+ nginxPath: string;
397
+ stateRoot: string;
398
+ };
399
+
400
+ const getUniqueMountSources = (
401
+ inspection: DockerContainerInspection,
402
+ ): Map<string, string> | undefined => {
403
+ const sources = new Map<string, string>();
404
+ for (const destination of CERTBOT_MOUNT_DESTINATIONS) {
405
+ const matching =
406
+ inspection.Mounts?.filter(
407
+ (mount) => mount.Destination === destination && mount.Source,
408
+ ) || [];
409
+ if (matching.length !== 1 || !matching[0].Source) return undefined;
410
+ sources.set(destination, matching[0].Source);
411
+ }
412
+ return sources;
413
+ };
414
+
415
+ /**
416
+ * Discard a staged domain generation only after Docker rollback has produced a
417
+ * complete inventory proving that no remaining transaction container mounts
418
+ * the generation. The active-generation marker is an additional durable guard.
419
+ */
420
+ export const discardDomainGenerationAfterRollback = async (
421
+ generationRoot: string,
422
+ remainingContainers: readonly DockerContainerInspection[],
423
+ ): Promise<boolean> => {
424
+ const fs = await import("fs");
425
+ const path = await import("path");
426
+ if (!fs.existsSync(generationRoot)) return true;
427
+
428
+ const resolvedGenerationRoot = fs.realpathSync(generationRoot);
429
+ const generation = path.basename(resolvedGenerationRoot);
430
+ const stateRoot = path.dirname(path.dirname(resolvedGenerationRoot));
431
+ const activePath = path.join(stateRoot, ACTIVE_DOMAIN_GENERATION_FILE);
432
+ if (fs.existsSync(activePath)) {
433
+ let activeGeneration: string;
434
+ try {
435
+ activeGeneration = normalizeDomainGenerationName(
436
+ fs.readFileSync(activePath, "utf8").trim(),
437
+ );
438
+ } catch {
439
+ return false;
440
+ }
441
+ if (activeGeneration === generation) return false;
442
+ }
443
+
444
+ const contains = (parent: string, candidate: string) => {
445
+ const relative = path.relative(parent, candidate);
446
+ return (
447
+ relative === "" ||
448
+ (!relative.startsWith(`..${path.sep}`) &&
449
+ relative !== ".." &&
450
+ !path.isAbsolute(relative))
451
+ );
452
+ };
453
+ for (const container of remainingContainers) {
454
+ for (const mount of container.Mounts || []) {
455
+ if (!mount.Source) continue;
456
+ let resolvedSource: string;
457
+ try {
458
+ resolvedSource = fs.realpathSync(mount.Source);
459
+ } catch {
460
+ resolvedSource = path.resolve(mount.Source);
461
+ }
462
+ if (
463
+ contains(resolvedGenerationRoot, resolvedSource) ||
464
+ contains(resolvedSource, resolvedGenerationRoot)
465
+ ) {
466
+ return false;
467
+ }
468
+ }
469
+ }
470
+
471
+ fs.rmSync(resolvedGenerationRoot, { recursive: true, force: true });
472
+ return true;
473
+ };
474
+
475
+ const getMountedDomainGeneration = async (
476
+ inspection: DockerContainerInspection,
477
+ ): Promise<MountedDomainGeneration | undefined> => {
478
+ const path = await import("path");
479
+ const sources = getUniqueMountSources(inspection);
480
+ const source = sources?.get("/etc/nginx/user_conf.d");
481
+ if (!source) return undefined;
482
+ const nginxPath = path.resolve(source);
483
+ if (path.basename(nginxPath) !== "nginx") return undefined;
484
+ const generationRoot = path.dirname(nginxPath);
485
+ const generation = path.basename(generationRoot);
486
+ const generationsRoot = path.dirname(generationRoot);
487
+ const stateRoot = path.dirname(generationsRoot);
488
+ if (
489
+ path.basename(generationsRoot) !== DOMAIN_GENERATIONS_FOLDER ||
490
+ path.basename(stateRoot) !== DOMAIN_STATE_FOLDER
491
+ ) {
492
+ return undefined;
493
+ }
494
+ try {
495
+ normalizeDomainGenerationName(generation);
496
+ } catch {
497
+ return undefined;
498
+ }
499
+ return {
500
+ configPath: path.join(nginxPath, "default.conf"),
501
+ generation,
502
+ nginxPath,
503
+ stateRoot,
504
+ };
505
+ };
506
+
507
+ const readActiveDomainGeneration = async (
508
+ stateRoot: string,
509
+ ): Promise<
510
+ | { kind: "missing" }
511
+ | { kind: "invalid" }
512
+ | { generation: string; kind: "value" }
513
+ > => {
514
+ const fs = await import("fs");
515
+ const path = await import("path");
516
+ const activePath = path.join(stateRoot, ACTIVE_DOMAIN_GENERATION_FILE);
517
+ if (!fs.existsSync(activePath)) return { kind: "missing" };
518
+ try {
519
+ return {
520
+ generation: normalizeDomainGenerationName(
521
+ fs.readFileSync(activePath, "utf8").trim(),
522
+ ),
523
+ kind: "value",
524
+ };
525
+ } catch {
526
+ return { kind: "invalid" };
527
+ }
528
+ };
529
+
530
+ /**
531
+ * Reconcile a crash that left both the replacement and its backup. The active
532
+ * generation file is the commit marker; malformed or cross-root state is left
533
+ * untouched for an operator rather than guessed at.
534
+ */
535
+ export const resolvePeerbitCertbotInterruptedPair = async (
536
+ current: DockerContainerInspection,
537
+ backup: DockerContainerInspection,
538
+ ): Promise<"keep-current" | "restore-backup" | undefined> => {
539
+ if (
540
+ !isPeerbitCertbotContainer(current) ||
541
+ !isPeerbitCertbotContainer(backup)
542
+ ) {
543
+ return undefined;
544
+ }
545
+ const currentSources = getUniqueMountSources(current);
546
+ const backupSources = getUniqueMountSources(backup);
547
+ if (!currentSources || !backupSources) return undefined;
548
+ const path = await import("path");
549
+ if (
550
+ path.resolve(currentSources.get("/etc/letsencrypt")!) !==
551
+ path.resolve(backupSources.get("/etc/letsencrypt")!)
552
+ ) {
553
+ return undefined;
554
+ }
555
+
556
+ const currentGeneration = await getMountedDomainGeneration(current);
557
+ if (!currentGeneration) return undefined;
558
+ const backupGeneration = await getMountedDomainGeneration(backup);
559
+ const legacyNginxPath = path.resolve(
560
+ path.dirname(currentGeneration.stateRoot),
561
+ "nginx",
562
+ );
563
+ if (
564
+ backupGeneration
565
+ ? backupGeneration.stateRoot !== currentGeneration.stateRoot
566
+ : path.resolve(backupSources.get("/etc/nginx/user_conf.d")!) !==
567
+ legacyNginxPath
568
+ ) {
569
+ return undefined;
570
+ }
571
+ const active = await readActiveDomainGeneration(currentGeneration.stateRoot);
572
+ const fs = await import("fs");
573
+ if (
574
+ active.kind === "value" &&
575
+ active.generation === currentGeneration.generation
576
+ ) {
577
+ const currentIsStable =
578
+ current.State?.Running === true &&
579
+ current.State.Restarting !== true &&
580
+ (current.RestartCount ?? 0) === 0 &&
581
+ current.State.Health?.Status !== "unhealthy";
582
+ return currentIsStable && fs.existsSync(currentGeneration.configPath)
583
+ ? "keep-current"
584
+ : undefined;
585
+ }
586
+ if (active.kind !== "value" && active.kind !== "missing") return undefined;
587
+
588
+ if (
589
+ backupGeneration &&
590
+ backupGeneration.stateRoot === currentGeneration.stateRoot &&
591
+ active.kind === "value" &&
592
+ active.generation === backupGeneration.generation &&
593
+ fs.existsSync(backupGeneration.configPath)
594
+ ) {
595
+ return "restore-backup";
596
+ }
597
+ if (active.kind !== "missing" || backupGeneration) return undefined;
598
+
599
+ return "restore-backup";
600
+ };
601
+
602
+ export const getCertbotSecretsPath = (
603
+ inspection: DockerContainerInspection | undefined,
604
+ fallback: string,
605
+ ): string => {
606
+ if (!inspection) return fallback;
607
+ const source = inspection.Mounts?.find(
608
+ (mount) => mount.Destination === "/etc/letsencrypt",
609
+ )?.Source;
610
+ if (!source) {
611
+ throw new Error(
612
+ "Existing Peerbit certificate container has no host certificate mount",
613
+ );
614
+ }
615
+ return source;
616
+ };
617
+
618
+ export const replaceUiFolder = async (source: string, destination: string) => {
619
+ const fs = await import("fs");
620
+ const path = await import("path");
621
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
622
+ const staging = `${destination}.peerbit-${process.pid}-${Date.now()}`;
623
+ fs.rmSync(staging, { recursive: true, force: true });
624
+ try {
625
+ fs.cpSync(source, staging, { recursive: true });
626
+ fs.rmSync(destination, { recursive: true, force: true });
627
+ fs.renameSync(staging, destination);
628
+ } finally {
629
+ fs.rmSync(staging, { recursive: true, force: true });
630
+ }
631
+ };
632
+
132
633
  export const startCertbot = async (
133
634
  domain: string,
134
635
  email: string,
135
636
  waitForUp = false,
136
637
  dockerProcessName = "nginx-certbot",
137
638
  ): Promise<void> => {
639
+ domain = normalizeDomain(domain);
640
+ dockerProcessName = normalizeDockerContainerName(dockerProcessName);
138
641
  if (!validateEmail(email)) {
139
- throw new Error("Email for SSL renenewal is invalid");
642
+ throw new Error("Email for SSL renewal is invalid");
140
643
  }
141
- const { installDocker, startContainer } = await import("./docker.js");
142
-
143
- const nginxConfigPath = await getNginxFolderPath();
144
- await createConfig(nginxConfigPath, domain);
644
+ const { installDocker, pullDockerImage, replaceDockerContainer } =
645
+ await import("./docker.js");
145
646
 
146
647
  await installDocker();
648
+ await pullDockerImage(CERTBOT_IMAGE);
147
649
 
148
- // run
149
650
  const isTest = process.env.JEST_WORKER_ID !== undefined;
150
-
151
- const uiPath = await getUIPath();
152
-
153
- // copy ui from node_modules to home for permission reasons (volume will not work otherwise)
154
- const certbotDockerCommand = `cp -r ${uiPath} $(pwd)/ui && docker pull jonasal/nginx-certbot:latest && docker run -d --net=host \
155
- --restart unless-stopped \
156
- --env CERTBOT_EMAIL=${email} ${isTest ? "--env STAGING=1" : ""}\
157
- -v $(pwd)/nginx_secrets:/etc/letsencrypt \
158
- -v ${nginxConfigPath}:/etc/nginx/user_conf.d:ro \
159
- -v $(pwd)/ui:/usr/share/nginx/html:ro \
160
- --name ${dockerProcessName} jonasal/nginx-certbot:latest`;
161
-
162
- console.log("Starting Certbot");
163
- // try two times with some delay, because sometimes the docker daemon is not available immidatel
164
- await startContainer(
165
- certbotDockerCommand,
166
- "Failed to start certbot container",
651
+ const fs = await import("fs");
652
+ const path = await import("path");
653
+ const { randomBytes, randomUUID } = await import("crypto");
654
+ const stateRoot = path.join(process.cwd(), DOMAIN_STATE_FOLDER);
655
+ const generation = normalizeDomainGenerationName(
656
+ `${Date.now()}-${randomUUID()}`,
167
657
  );
658
+ const generationRoot = path.join(
659
+ stateRoot,
660
+ DOMAIN_GENERATIONS_FOLDER,
661
+ generation,
662
+ );
663
+ const nginxConfigPath = path.join(generationRoot, "nginx");
664
+ const uiPath = path.join(generationRoot, "ui");
665
+ const readinessChallenge = randomBytes(32).toString("hex");
666
+ let activated = false;
667
+ let replacementPrepared = false;
668
+ let rollbackDispositionHandled = false;
669
+ try {
670
+ await replaceUiFolder(await getUIPath(), uiPath);
671
+ const readinessChallengeDirectory = path.join(
672
+ uiPath,
673
+ ".well-known",
674
+ "peerbit-generation",
675
+ );
676
+ fs.mkdirSync(readinessChallengeDirectory, { recursive: true });
677
+ fs.writeFileSync(
678
+ path.join(readinessChallengeDirectory, readinessChallenge),
679
+ readinessChallenge,
680
+ "utf8",
681
+ );
682
+ await createConfig(nginxConfigPath, domain);
683
+
684
+ // Reconfiguration keeps the old container and generation as a stopped
685
+ // backup until the new container and active-generation pointer both succeed.
686
+ console.log("Starting Certbot");
687
+ await replaceDockerContainer(
688
+ dockerProcessName,
689
+ (existingContainer) => {
690
+ replacementPrepared = true;
691
+ return buildCertbotContainerArguments({
692
+ containerName: dockerProcessName,
693
+ email,
694
+ nginxConfigPath,
695
+ secretsPath: getCertbotSecretsPath(
696
+ existingContainer,
697
+ path.join(stateRoot, "nginx_secrets"),
698
+ ),
699
+ uiPath,
700
+ staging: isTest,
701
+ });
702
+ },
703
+ {
704
+ onCommitted: async () => {
705
+ try {
706
+ await pruneDomainGenerations(stateRoot, generation);
707
+ } catch (error: any) {
708
+ console.warn(
709
+ `Domain configuration succeeded, but stale generations could not be pruned: ${error?.message || error}`,
710
+ );
711
+ }
712
+ },
713
+ onCommit: async () => {
714
+ await activateDomainGeneration(stateRoot, generation);
715
+ activated = true;
716
+ },
717
+ onRollbackInspected: async (remainingContainers) => {
718
+ rollbackDispositionHandled = true;
719
+ await discardDomainGenerationAfterRollback(
720
+ generationRoot,
721
+ remainingContainers,
722
+ );
723
+ },
724
+ onStarted: async () => {
725
+ if (waitForUp) {
726
+ console.log("Waiting for domain to be ready ...");
727
+ await waitForPeerbitDomain(domain, {
728
+ challengeToken: readinessChallenge,
729
+ });
730
+ }
731
+ },
732
+ resolveInterruptedPair: resolvePeerbitCertbotInterruptedPair,
733
+ validateExisting: isPeerbitCertbotContainer,
734
+ },
735
+ );
736
+ } catch (error) {
737
+ if (!activated && !replacementPrepared && !rollbackDispositionHandled) {
738
+ fs.rmSync(generationRoot, { recursive: true, force: true });
739
+ }
740
+ throw error;
741
+ }
168
742
 
169
743
  console.log("Certbot started succesfully!");
170
744
  console.log("You domain is: ");
171
745
  console.log(domain);
172
746
  if (waitForUp) {
173
- const { default: axios } = await import("axios");
174
-
175
- console.log("Waiting for domain to be ready ...");
176
- await waitFor(
177
- async () => {
178
- try {
179
- const status = (await axios.get("https://" + domain)).status;
180
- return status >= 200 && status < 400;
181
- } catch (error) {
182
- return false;
183
- }
184
- },
185
- { timeout: 5 * 60 * 10000, delayInterval: 5000 },
186
- );
187
747
  console.log("Domain is ready");
188
748
  } else {
189
749
  console.log(