@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.
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +161 -352
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +1 -1
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +2 -5
- package/dist/src/client.js.map +1 -1
- package/dist/src/docker.d.ts +70 -1
- package/dist/src/docker.d.ts.map +1 -1
- package/dist/src/docker.js +559 -14
- package/dist/src/docker.js.map +1 -1
- package/dist/src/domain-lease.d.ts +126 -0
- package/dist/src/domain-lease.d.ts.map +1 -0
- package/dist/src/domain-lease.js +800 -0
- package/dist/src/domain-lease.js.map +1 -0
- package/dist/src/domain.d.ts +50 -2
- package/dist/src/domain.d.ts.map +1 -1
- package/dist/src/domain.js +484 -67
- package/dist/src/domain.js.map +1 -1
- package/dist/src/hetzner.d.ts +4 -21
- package/dist/src/hetzner.d.ts.map +1 -1
- package/dist/src/hetzner.js +4 -116
- package/dist/src/hetzner.js.map +1 -1
- package/dist/src/nginx-template.conf +19 -1
- package/dist/src/remotes.browser.d.ts +4 -1
- package/dist/src/remotes.browser.d.ts.map +1 -1
- package/dist/src/remotes.browser.js +1 -2
- package/dist/src/remotes.browser.js.map +1 -1
- package/dist/src/remotes.d.ts +2 -1
- package/dist/src/remotes.d.ts.map +1 -1
- package/dist/src/remotes.js +1 -0
- package/dist/src/remotes.js.map +1 -1
- package/dist/src/server.d.ts.map +1 -1
- package/dist/src/server.js +3 -5
- package/dist/src/server.js.map +1 -1
- package/dist/test/api.spec.js +2 -1
- package/dist/test/api.spec.js.map +1 -1
- package/dist/test/cli.spec.js +48 -1
- package/dist/test/cli.spec.js.map +1 -1
- package/dist/test/docker.spec.d.ts +2 -0
- package/dist/test/docker.spec.d.ts.map +1 -0
- package/dist/test/docker.spec.js +822 -0
- package/dist/test/docker.spec.js.map +1 -0
- package/dist/test/domain-lease.spec.d.ts +2 -0
- package/dist/test/domain-lease.spec.d.ts.map +1 -0
- package/dist/test/domain-lease.spec.js +530 -0
- package/dist/test/domain-lease.spec.js.map +1 -0
- package/dist/test/domain.integration.spec.js +358 -75
- package/dist/test/domain.integration.spec.js.map +1 -1
- package/dist/test/remotes.spec.d.ts +2 -0
- package/dist/test/remotes.spec.d.ts.map +1 -0
- package/dist/test/remotes.spec.js +15 -0
- package/dist/test/remotes.spec.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/ui/assets/index-CjZwCewh.js +10 -0
- package/dist/ui/assets/index-e3g42MbC.js +9 -0
- package/dist/ui/index.html +1 -1
- package/package.json +14 -19
- package/src/cli.ts +204 -408
- package/src/client.ts +2 -6
- package/src/docker.ts +841 -20
- package/src/domain-lease.ts +1118 -0
- package/src/domain.ts +650 -90
- package/src/hetzner.ts +4 -184
- package/src/nginx-template.conf +19 -1
- package/src/remotes.browser.ts +7 -1
- package/src/remotes.ts +6 -1
- package/src/server.ts +3 -5
- package/dist/src/aws.browser.d.ts +0 -2
- package/dist/src/aws.browser.d.ts.map +0 -1
- package/dist/src/aws.browser.js +0 -3
- package/dist/src/aws.browser.js.map +0 -1
- package/dist/src/aws.d.ts +0 -30
- package/dist/src/aws.d.ts.map +0 -1
- package/dist/src/aws.js +0 -240
- package/dist/src/aws.js.map +0 -1
- package/dist/test/launch.spec.d.ts +0 -2
- package/dist/test/launch.spec.d.ts.map +0 -1
- package/dist/test/launch.spec.js +0 -23
- package/dist/test/launch.spec.js.map +0 -1
- package/dist/ui/assets/index-BBTKOM0z.js +0 -6
- package/dist/ui/assets/index-DVtj_GOf.js +0 -13
- package/src/aws.browser.ts +0 -1
- package/src/aws.ts +0 -327
package/src/domain.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
/* eslint-disable no-console */
|
|
2
|
-
import {
|
|
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
|
|
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(
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
642
|
+
throw new Error("Email for SSL renewal is invalid");
|
|
140
643
|
}
|
|
141
|
-
const { installDocker,
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
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(
|