openship 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -61
- package/dist/bare-ITM45QBX.js +18 -0
- package/dist/bowser-MFQA6OS4.js +2821 -0
- package/dist/chunk-3U4SPZD5.js +241 -0
- package/dist/chunk-46YJFP4S.js +1771 -0
- package/dist/chunk-4CQWT2WL.js +116 -0
- package/dist/chunk-4FABQGQP.js +23 -0
- package/dist/chunk-4LEL3H2Z.js +84 -0
- package/dist/chunk-4MG3BVHE.js +326 -0
- package/dist/chunk-4VNS5WPM.js +42 -0
- package/dist/chunk-5N7VUGL6.js +2094 -0
- package/dist/chunk-5ZCKQ2MI.js +1360 -0
- package/dist/chunk-6ERIMTBC.js +1412 -0
- package/dist/chunk-7UOB5GGQ.js +132 -0
- package/dist/chunk-BUBSSKO6.js +1384 -0
- package/dist/chunk-E6W2JMMU.js +341 -0
- package/dist/chunk-E77LJWJK.js +1535 -0
- package/dist/chunk-F4DFVG6Q.js +573 -0
- package/dist/chunk-HFYDYK4D.js +1092 -0
- package/dist/chunk-JR2OCMJA.js +46 -0
- package/dist/chunk-JUJBBF5U.js +746 -0
- package/dist/chunk-JZLNHGLW.js +19145 -0
- package/dist/chunk-KD7VO4LG.js +2617 -0
- package/dist/chunk-LBFH7THC.js +435 -0
- package/dist/chunk-NB2VEIWG.js +129 -0
- package/dist/chunk-OJE6YWPQ.js +2866 -0
- package/dist/chunk-QKFRZNKN.js +4941 -0
- package/dist/chunk-RBZPFTHV.js +13354 -0
- package/dist/chunk-S6DSRLNE.js +121 -0
- package/dist/chunk-TEYJYSMT.js +409 -0
- package/dist/chunk-U2DPZSXA.js +112 -0
- package/dist/chunk-WJPCBU7Z.js +3147 -0
- package/dist/chunk-XIFRMKYT.js +13 -0
- package/dist/chunk-XVSAUO2I.js +7946 -0
- package/dist/chunk-Y37KHU7L.js +31 -0
- package/dist/chunk-YL67EWYZ.js +416 -0
- package/dist/chunk-ZKJOFWBJ.js +1879 -0
- package/dist/cloud-JMPHO3CW.js +7 -0
- package/dist/cloud-W5G6YXAY.js +24 -0
- package/dist/dist-L3MPOWCA.js +65 -0
- package/dist/dist-es-4C322O5D.js +22 -0
- package/dist/dist-es-GTAMF674.js +87 -0
- package/dist/dist-es-KE56MWYO.js +169 -0
- package/dist/dist-es-NRZIQY5B.js +484 -0
- package/dist/dist-es-WZCR7DHQ.js +317 -0
- package/dist/dist-es-XQCOTGC7.js +376 -0
- package/dist/dist-es-ZW3HFQUM.js +69 -0
- package/dist/docker-3FVJMFC3.js +12 -0
- package/dist/docker-edge-executor-ZIWAZWPL.js +9 -0
- package/dist/edge-import-53M5DGWZ.js +39 -0
- package/dist/event-streams-7LGDL56D.js +42 -0
- package/dist/executor-J7BHW5HF.js +26 -0
- package/dist/index.js +2459 -3461
- package/dist/loadSso-N273C5I7.js +577 -0
- package/dist/nginx-YZWKYKQU.js +16 -0
- package/dist/noop-RRNCIUI5.js +7 -0
- package/dist/openresty-lua-HAO4EUDY.js +32 -0
- package/dist/server/index.js +217916 -205055
- package/dist/server/migrations/0057_complete_prism.sql +18 -0
- package/dist/server/migrations/0058_add_server_is_local.sql +1 -0
- package/dist/server/migrations/0059_legal_fixer.sql +18 -0
- package/dist/server/migrations/0060_add_route_strategy_host_port.sql +3 -0
- package/dist/server/migrations/0061_notification_default_multi_kind.sql +3 -0
- package/dist/server/migrations/0062_add_incoming_webhook.sql +21 -0
- package/dist/server/migrations/0063_add_migration_logs.sql +1 -0
- package/dist/server/migrations/0064_add_migration_partial.sql +2 -0
- package/dist/server/migrations/0065_custom_app_template.sql +14 -0
- package/dist/server/migrations/0066_ensure_migration_partial_columns.sql +2 -0
- package/dist/server/migrations/0067_webhook_delivery.sql +28 -0
- package/dist/server/migrations/0068_add_migration_target_volumes.sql +1 -0
- package/dist/server/migrations/0069_add_project_composite_routes.sql +1 -0
- package/dist/server/migrations/0070_forward_git_to_server.sql +1 -0
- package/dist/server/migrations/meta/0057_snapshot.json +9326 -0
- package/dist/server/migrations/meta/0058_snapshot.json +9333 -0
- package/dist/server/migrations/meta/0059_snapshot.json +9495 -0
- package/dist/server/migrations/meta/0060_snapshot.json +9515 -0
- package/dist/server/migrations/meta/_journal.json +99 -1
- package/dist/setup-5X3WEEHA.js +14 -0
- package/dist/signin-DR462ERO.js +663 -0
- package/dist/sso-oidc-VBZLJUK5.js +830 -0
- package/dist/sts-EJLZ4Z77.js +1422 -0
- package/package.json +6 -2
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sq
|
|
3
|
+
} from "./chunk-4CQWT2WL.js";
|
|
4
|
+
import {
|
|
5
|
+
ACME_CHALLENGE_LOCATION,
|
|
6
|
+
ACME_HTTP01_PORT,
|
|
7
|
+
LUA_LOGGER_PATH,
|
|
8
|
+
RULES_GUARD_PATH,
|
|
9
|
+
buildReloadCommand,
|
|
10
|
+
detectOpenRestyPaths,
|
|
11
|
+
luaSourceAvailable
|
|
12
|
+
} from "./chunk-E6W2JMMU.js";
|
|
13
|
+
import {
|
|
14
|
+
PROXY_GZIP_TYPES,
|
|
15
|
+
safeErrorMessage,
|
|
16
|
+
sanitizeProxySettings
|
|
17
|
+
} from "./chunk-JZLNHGLW.js";
|
|
18
|
+
|
|
19
|
+
// ../../packages/adapters/src/infra/nginx.ts
|
|
20
|
+
import {
|
|
21
|
+
access as fsAccess,
|
|
22
|
+
writeFile as fsWriteFile,
|
|
23
|
+
rm as fsRm,
|
|
24
|
+
mkdir as fsMkdir,
|
|
25
|
+
readFile as fsReadFile,
|
|
26
|
+
rename as fsRename
|
|
27
|
+
} from "fs/promises";
|
|
28
|
+
import { execFile as cpExecFile } from "child_process";
|
|
29
|
+
import { randomBytes, X509Certificate, createPrivateKey } from "crypto";
|
|
30
|
+
import { promisify } from "util";
|
|
31
|
+
import { dirname, join } from "path";
|
|
32
|
+
var PROXY_HEADERS = `proxy_set_header Host $host;
|
|
33
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
34
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
35
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
36
|
+
proxy_http_version 1.1;
|
|
37
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
38
|
+
proxy_set_header Connection "upgrade";`;
|
|
39
|
+
function renderProxyLocations(route) {
|
|
40
|
+
if (!route.proxyLocations || route.proxyLocations.length === 0) return "";
|
|
41
|
+
return route.proxyLocations.map((loc) => {
|
|
42
|
+
assertValidUpstream(loc.targetUrl);
|
|
43
|
+
return `
|
|
44
|
+
location ${loc.pathPrefix} {
|
|
45
|
+
proxy_pass ${loc.targetUrl};
|
|
46
|
+
${PROXY_HEADERS}
|
|
47
|
+
}`;
|
|
48
|
+
}).join("");
|
|
49
|
+
}
|
|
50
|
+
function renderRedirects(route) {
|
|
51
|
+
if (!route.redirects || route.redirects.length === 0) return "";
|
|
52
|
+
return route.redirects.map(
|
|
53
|
+
(r) => `
|
|
54
|
+
location ${r.exact ? "= " : ""}${r.path} {
|
|
55
|
+
return ${r.statusCode} ${r.destination};
|
|
56
|
+
}`
|
|
57
|
+
).join("");
|
|
58
|
+
}
|
|
59
|
+
function renderServerHeaders(route) {
|
|
60
|
+
const global = (route.headerRules ?? []).filter((rule) => rule.path === "/");
|
|
61
|
+
const lines = global.flatMap(
|
|
62
|
+
(rule) => rule.headers.map((h) => ` add_header ${h.key} "${h.value.replace(/"/g, '\\"')}" always;`)
|
|
63
|
+
);
|
|
64
|
+
return lines.length > 0 ? `
|
|
65
|
+
${lines.join("\n")}` : "";
|
|
66
|
+
}
|
|
67
|
+
function renderProxyOptions(proxy) {
|
|
68
|
+
const p = sanitizeProxySettings(proxy);
|
|
69
|
+
if (!p) return "";
|
|
70
|
+
const lines = [];
|
|
71
|
+
if (p.clientMaxBodySize) lines.push(` client_max_body_size ${p.clientMaxBodySize};`);
|
|
72
|
+
if (p.proxyReadTimeout) lines.push(` proxy_read_timeout ${p.proxyReadTimeout};`);
|
|
73
|
+
if (p.proxySendTimeout) lines.push(` proxy_send_timeout ${p.proxySendTimeout};`);
|
|
74
|
+
if (p.clientBodyTimeout) lines.push(` client_body_timeout ${p.clientBodyTimeout};`);
|
|
75
|
+
if (p.proxyBuffering !== void 0) {
|
|
76
|
+
lines.push(` proxy_buffering ${p.proxyBuffering ? "on" : "off"};`);
|
|
77
|
+
}
|
|
78
|
+
if (p.gzip !== void 0) {
|
|
79
|
+
lines.push(` gzip ${p.gzip ? "on" : "off"};`);
|
|
80
|
+
if (p.gzip) {
|
|
81
|
+
lines.push(` gzip_types ${PROXY_GZIP_TYPES};`);
|
|
82
|
+
lines.push(` gzip_min_length 1024;`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return lines.length > 0 ? `
|
|
86
|
+
${lines.join("\n")}` : "";
|
|
87
|
+
}
|
|
88
|
+
function renderGeoEntries(whitelist) {
|
|
89
|
+
const entries = [
|
|
90
|
+
" default 0;",
|
|
91
|
+
" 127.0.0.1/32 1;",
|
|
92
|
+
" ::1/128 1;"
|
|
93
|
+
];
|
|
94
|
+
for (const cidr of whitelist) {
|
|
95
|
+
if (/^[\da-fA-F.:\/]+$/.test(cidr) && cidr.length <= 50) {
|
|
96
|
+
entries.push(` ${cidr.padEnd(15)} 1;`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return entries;
|
|
100
|
+
}
|
|
101
|
+
var DEFAULT_CERT_DIR = "/etc/letsencrypt/live";
|
|
102
|
+
var DOMAIN_RE = /^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/;
|
|
103
|
+
function assertValidDomain(domain) {
|
|
104
|
+
if (!DOMAIN_RE.test(domain) || domain.length > 253) {
|
|
105
|
+
throw new Error(`Invalid domain: ${domain}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function assertNoNginxInjection(value, what) {
|
|
109
|
+
if (/[\s;{}#\\]/.test(value)) {
|
|
110
|
+
throw new Error(`Invalid ${what} (contains characters that could inject nginx config): ${value}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function assertValidUpstream(targetUrl) {
|
|
114
|
+
assertNoNginxInjection(targetUrl, "proxy target");
|
|
115
|
+
if (!/^https?:\/\/.+/.test(targetUrl)) {
|
|
116
|
+
throw new Error(`Invalid proxy target (must be http/https URL): ${targetUrl}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function assertValidStaticRoot(root) {
|
|
120
|
+
assertNoNginxInjection(root, "static root");
|
|
121
|
+
if (!root.startsWith("/") || root.includes("..")) {
|
|
122
|
+
throw new Error(`Invalid static root (must be an absolute path, no traversal): ${root}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
var execFileAsync = promisify(cpExecFile);
|
|
126
|
+
function summarizeCertbotFailure(output, domain) {
|
|
127
|
+
const text = output || "";
|
|
128
|
+
const pick = (re) => text.match(re)?.[0]?.replace(/\s+/g, " ").trim();
|
|
129
|
+
const detail = pick(/Detail:\s*[^\n]+/i);
|
|
130
|
+
const hint = pick(/Hint:\s*[^\n]+/i);
|
|
131
|
+
let diagnosis;
|
|
132
|
+
if (/timeout during connect|connection refused|failed to connect|Timeout/i.test(text)) {
|
|
133
|
+
diagnosis = `Port 80 for ${domain} isn't reachable from the internet \u2014 a firewall / cloud security group is blocking it, the domain doesn't point at this server, or another proxy is still bound to :80.`;
|
|
134
|
+
} else if (/NXDOMAIN|no\s+(A|AAAA)\s+record|DNS problem|could not be resolved|no records? found/i.test(text)) {
|
|
135
|
+
diagnosis = `${domain} doesn't resolve to this server yet \u2014 the DNS A record is missing or hasn't propagated. Wait for propagation, then retry from the Domains tab.`;
|
|
136
|
+
} else if (/404|invalid response|unauthorized|"?status"?:?\s*40\d/i.test(text)) {
|
|
137
|
+
diagnosis = `Port 80 answered but not with our ACME challenge \u2014 another web server is still serving :80, so the takeover didn't complete. Re-run and choose take-over / migrate.`;
|
|
138
|
+
} else if (/too many certificates|rateLimited|rate limit/i.test(text)) {
|
|
139
|
+
diagnosis = `Let's Encrypt rate limit reached for ${domain} \u2014 wait before retrying.`;
|
|
140
|
+
}
|
|
141
|
+
const parts = [diagnosis, detail, hint].filter(Boolean);
|
|
142
|
+
if (parts.length > 0) return parts.join(" \u2014 ");
|
|
143
|
+
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
144
|
+
return lines.slice(-3).join(" \xB7 ") || `certbot failed to issue a certificate for ${domain}`;
|
|
145
|
+
}
|
|
146
|
+
var NginxProvider = class {
|
|
147
|
+
sitesDir;
|
|
148
|
+
acmeEmail;
|
|
149
|
+
certDir;
|
|
150
|
+
executor;
|
|
151
|
+
reloadCommand;
|
|
152
|
+
constructor(opts) {
|
|
153
|
+
this.sitesDir = opts.paths.sitesDir;
|
|
154
|
+
this.acmeEmail = opts.acmeEmail;
|
|
155
|
+
this.certDir = opts.certDir ?? DEFAULT_CERT_DIR;
|
|
156
|
+
this.executor = opts.executor ?? null;
|
|
157
|
+
this.reloadCommand = buildReloadCommand(opts.paths);
|
|
158
|
+
}
|
|
159
|
+
// ── File operation helpers (dual-path: local or remote) ──────────────
|
|
160
|
+
async _writeFile(path, content) {
|
|
161
|
+
const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
162
|
+
if (this.executor) {
|
|
163
|
+
try {
|
|
164
|
+
await this.executor.writeFile(tmpPath, content);
|
|
165
|
+
await this.executor.exec(`mv ${sq(tmpPath)} ${sq(path)}`);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
await this.executor.rm(tmpPath).catch(() => void 0);
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
await fsMkdir(dirname(path), { recursive: true });
|
|
172
|
+
try {
|
|
173
|
+
await fsWriteFile(tmpPath, content, "utf-8");
|
|
174
|
+
await fsRename(tmpPath, path);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
await fsRm(tmpPath).catch(() => void 0);
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async _readFile(path) {
|
|
182
|
+
if (this.executor) {
|
|
183
|
+
return this.executor.readFile(path);
|
|
184
|
+
}
|
|
185
|
+
return fsReadFile(path, "utf-8");
|
|
186
|
+
}
|
|
187
|
+
async _exists(path) {
|
|
188
|
+
if (this.executor) {
|
|
189
|
+
return this.executor.exists(path);
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
await fsAccess(path);
|
|
193
|
+
return true;
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async _rm(path) {
|
|
199
|
+
if (this.executor) {
|
|
200
|
+
await this.executor.rm(path);
|
|
201
|
+
} else {
|
|
202
|
+
try {
|
|
203
|
+
await fsRm(path);
|
|
204
|
+
} catch {
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async _mkdir(path) {
|
|
209
|
+
if (this.executor) {
|
|
210
|
+
await this.executor.mkdir(path);
|
|
211
|
+
} else {
|
|
212
|
+
await fsMkdir(path, { recursive: true });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async _exec(command, args = []) {
|
|
216
|
+
if (this.executor) {
|
|
217
|
+
const full = args.length ? `${command} ${args.map(sq).join(" ")}` : command;
|
|
218
|
+
return this.executor.exec(full);
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const { stdout } = await execFileAsync(command, args);
|
|
222
|
+
return stdout;
|
|
223
|
+
} catch (err) {
|
|
224
|
+
const e = err;
|
|
225
|
+
const detail = [e.stderr?.trim(), e.stdout?.trim()].filter(Boolean).join("\n");
|
|
226
|
+
throw new Error(detail || e.message || String(err));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Run certbot, optionally STREAMING its output line-by-line to `onLog` (the
|
|
231
|
+
* live verify modal). Falls back to buffered `_exec` when no callback / no
|
|
232
|
+
* executor. `streamExec` doesn't reject on a non-zero exit — it returns the
|
|
233
|
+
* code — so mirror `_exec`'s throw-with-output so `summarizeCertbotFailure`
|
|
234
|
+
* still sees the real cause.
|
|
235
|
+
*/
|
|
236
|
+
async _execCertbot(args, onLog) {
|
|
237
|
+
if (!onLog || !this.executor) {
|
|
238
|
+
const out = await this._exec("certbot", args);
|
|
239
|
+
if (out) onLog?.(out);
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
const full = `certbot ${args.map(sq).join(" ")}`;
|
|
243
|
+
let output = "";
|
|
244
|
+
const { code } = await this.executor.streamExec(full, (log) => {
|
|
245
|
+
output += log.message;
|
|
246
|
+
onLog(log.message);
|
|
247
|
+
});
|
|
248
|
+
if (code !== 0) throw new Error(output.trim() || `certbot exited ${code}`);
|
|
249
|
+
return output;
|
|
250
|
+
}
|
|
251
|
+
async _captureFile(path) {
|
|
252
|
+
if (!await this._exists(path)) {
|
|
253
|
+
return { exists: false };
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
exists: true,
|
|
257
|
+
content: await this._readFile(path)
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
async _restoreFile(path, snapshot) {
|
|
261
|
+
if (!snapshot.exists) {
|
|
262
|
+
await this._rm(path);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
await this._writeFile(path, snapshot.content ?? "");
|
|
266
|
+
}
|
|
267
|
+
// ── Routing ──────────────────────────────────────────────────────────
|
|
268
|
+
/**
|
|
269
|
+
* Register a route by writing an OpenResty server block.
|
|
270
|
+
*
|
|
271
|
+
* Creates a conf file in sites-enabled, then reloads.
|
|
272
|
+
* If TLS is enabled and certs exist, configures SSL. If certs don't
|
|
273
|
+
* exist yet, writes an HTTP-only block (certbot will add SSL later
|
|
274
|
+
* via provisionCert).
|
|
275
|
+
*/
|
|
276
|
+
async registerRoute(route) {
|
|
277
|
+
assertValidDomain(route.domain);
|
|
278
|
+
await this._mkdir(this.sitesDir);
|
|
279
|
+
const slug = this.domainSlug(route.domain);
|
|
280
|
+
const configPath = join(this.sitesDir, `${slug}.conf`);
|
|
281
|
+
if ("staticRoot" in route && route.staticRoot) {
|
|
282
|
+
assertValidStaticRoot(route.staticRoot);
|
|
283
|
+
} else {
|
|
284
|
+
assertValidUpstream(route.targetUrl);
|
|
285
|
+
}
|
|
286
|
+
const locationBody = "staticRoot" in route && route.staticRoot ? `root ${route.staticRoot};
|
|
287
|
+
index index.html;
|
|
288
|
+
try_files $uri $uri/ /index.html;` : `proxy_pass ${route.targetUrl};
|
|
289
|
+
${PROXY_HEADERS}`;
|
|
290
|
+
const extraLocations = `${renderRedirects(route)}${renderProxyLocations(route)}`;
|
|
291
|
+
const serverHeaders = renderServerHeaders(route);
|
|
292
|
+
const proxyOpts = renderProxyOptions(route.proxy);
|
|
293
|
+
const webhookLocation = route.webhookProxy ? `
|
|
294
|
+
location /_openship/hooks/ {
|
|
295
|
+
proxy_pass ${route.webhookProxy};
|
|
296
|
+
proxy_set_header Host $host;
|
|
297
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
298
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
299
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
300
|
+
}
|
|
301
|
+
` : "";
|
|
302
|
+
const edgeLua = luaSourceAvailable();
|
|
303
|
+
const luaLogOnly = edgeLua ? ` log_by_lua_file ${LUA_LOGGER_PATH};` : "";
|
|
304
|
+
const luaProxy = edgeLua ? ` log_by_lua_file ${LUA_LOGGER_PATH};
|
|
305
|
+
access_by_lua_file ${RULES_GUARD_PATH};` : "";
|
|
306
|
+
let serverBlock;
|
|
307
|
+
if (route.tls && await this.certsExist(route.domain)) {
|
|
308
|
+
const certPath = join(this.certDir, route.domain, "fullchain.pem");
|
|
309
|
+
const keyPath = join(this.certDir, route.domain, "privkey.pem");
|
|
310
|
+
serverBlock = `# Auto-generated by Openship - do not edit manually
|
|
311
|
+
server {
|
|
312
|
+
listen 80;
|
|
313
|
+
server_name ${route.domain};
|
|
314
|
+
|
|
315
|
+
${luaLogOnly}
|
|
316
|
+
|
|
317
|
+
${ACME_CHALLENGE_LOCATION}
|
|
318
|
+
|
|
319
|
+
location / {
|
|
320
|
+
return 301 https://$server_name$request_uri;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
server {
|
|
325
|
+
listen 443 ssl;
|
|
326
|
+
server_name ${route.domain};
|
|
327
|
+
|
|
328
|
+
${luaProxy}
|
|
329
|
+
|
|
330
|
+
ssl_certificate ${certPath};
|
|
331
|
+
ssl_certificate_key ${keyPath};
|
|
332
|
+
${serverHeaders}${proxyOpts}
|
|
333
|
+
${webhookLocation}${extraLocations}
|
|
334
|
+
location / {
|
|
335
|
+
${locationBody}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
`;
|
|
339
|
+
} else {
|
|
340
|
+
serverBlock = `# Auto-generated by Openship - do not edit manually
|
|
341
|
+
server {
|
|
342
|
+
listen 80;
|
|
343
|
+
server_name ${route.domain};
|
|
344
|
+
|
|
345
|
+
${luaProxy}
|
|
346
|
+
${serverHeaders}${proxyOpts}
|
|
347
|
+
${ACME_CHALLENGE_LOCATION}
|
|
348
|
+
${webhookLocation}${extraLocations}
|
|
349
|
+
location / {
|
|
350
|
+
${locationBody}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
`;
|
|
354
|
+
}
|
|
355
|
+
const snapshot = await this._captureFile(configPath);
|
|
356
|
+
await this._writeFile(configPath, serverBlock);
|
|
357
|
+
try {
|
|
358
|
+
await this.reload();
|
|
359
|
+
} catch (err) {
|
|
360
|
+
await this._restoreFile(configPath, snapshot);
|
|
361
|
+
await this.reload().catch(() => void 0);
|
|
362
|
+
throw err;
|
|
363
|
+
}
|
|
364
|
+
await this._writeFile(this.routeStatePath(slug), JSON.stringify(route)).catch(() => void 0);
|
|
365
|
+
}
|
|
366
|
+
routeStatePath(slug) {
|
|
367
|
+
return join(this.sitesDir, `${slug}.route.json`);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Remove a route by deleting its conf + route-state files, then reload.
|
|
371
|
+
*
|
|
372
|
+
* Self-rollback (same pattern as registerRoute): snapshot both files first, and
|
|
373
|
+
* if the post-removal `openresty -t`/reload fails — usually because an UNRELATED
|
|
374
|
+
* vhost is broken — restore them so the edge on disk == the running config.
|
|
375
|
+
* Without this, a failed reload left the route GONE from disk but still served
|
|
376
|
+
* from the old in-memory config (an inconsistent half-state that would silently
|
|
377
|
+
* vanish on the next unrelated reload). The error is re-thrown for retry.
|
|
378
|
+
*/
|
|
379
|
+
async removeRoute(domain) {
|
|
380
|
+
assertValidDomain(domain);
|
|
381
|
+
const slug = this.domainSlug(domain);
|
|
382
|
+
const configPath = join(this.sitesDir, `${slug}.conf`);
|
|
383
|
+
const statePath = this.routeStatePath(slug);
|
|
384
|
+
const confSnapshot = await this._captureFile(configPath);
|
|
385
|
+
const stateSnapshot = await this._captureFile(statePath);
|
|
386
|
+
await this._rm(configPath);
|
|
387
|
+
await this._rm(statePath).catch(() => void 0);
|
|
388
|
+
try {
|
|
389
|
+
await this.reload();
|
|
390
|
+
} catch (err) {
|
|
391
|
+
await this._restoreFile(configPath, confSnapshot);
|
|
392
|
+
await this._restoreFile(statePath, stateSnapshot);
|
|
393
|
+
await this.reload().catch(() => void 0);
|
|
394
|
+
throw err;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// ── SSL ──────────────────────────────────────────────────────────────
|
|
398
|
+
/**
|
|
399
|
+
* Provision a TLS certificate using certbot.
|
|
400
|
+
*
|
|
401
|
+
* Runs `certbot certonly` in webroot mode using the ACME challenge
|
|
402
|
+
* directory served by OpenResty, then rewrites the config to include
|
|
403
|
+
* SSL and reloads.
|
|
404
|
+
*
|
|
405
|
+
* Only --webroot is attempted. The previous --standalone fallback was
|
|
406
|
+
* dead code on any normal install: certbot --standalone binds to port
|
|
407
|
+
* 80 itself, but OpenResty already owns 80 on the same box, so the
|
|
408
|
+
* fallback would always fail with EADDRINUSE — amplifying a transient
|
|
409
|
+
* --webroot failure into an immediate hard error. The caller
|
|
410
|
+
* (route-registration.ts) already wraps provisionCert in try/catch so
|
|
411
|
+
* a webroot failure becomes a "deploy continues on HTTP, retry from
|
|
412
|
+
* Domains tab" warning instead of a deploy abort.
|
|
413
|
+
*/
|
|
414
|
+
async provisionCert(domain, opts) {
|
|
415
|
+
assertValidDomain(domain);
|
|
416
|
+
if (!opts?.force && await this.certsExist(domain)) {
|
|
417
|
+
return this.readCertInfo(domain);
|
|
418
|
+
}
|
|
419
|
+
const validEmail = !!this.acmeEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.acmeEmail);
|
|
420
|
+
const emailArgs = validEmail ? ["--email", this.acmeEmail] : ["--register-unsafely-without-email"];
|
|
421
|
+
let certonlyOut = "";
|
|
422
|
+
try {
|
|
423
|
+
certonlyOut = await this._execCertbot([
|
|
424
|
+
"certonly",
|
|
425
|
+
"--standalone",
|
|
426
|
+
"--http-01-port",
|
|
427
|
+
String(ACME_HTTP01_PORT),
|
|
428
|
+
"--cert-name",
|
|
429
|
+
domain,
|
|
430
|
+
"-d",
|
|
431
|
+
domain,
|
|
432
|
+
...emailArgs,
|
|
433
|
+
"--agree-tos",
|
|
434
|
+
"--non-interactive",
|
|
435
|
+
// Forced reissue: certbot would otherwise print "not due for renewal"
|
|
436
|
+
// (exit 0) when a lineage exists, leaving the stale cert in place.
|
|
437
|
+
...opts?.force ? ["--force-renewal"] : []
|
|
438
|
+
], opts?.onLog);
|
|
439
|
+
} catch (err) {
|
|
440
|
+
throw new Error(summarizeCertbotFailure(safeErrorMessage(err), domain));
|
|
441
|
+
}
|
|
442
|
+
const slug = this.domainSlug(domain);
|
|
443
|
+
const configPath = join(this.sitesDir, `${slug}.conf`);
|
|
444
|
+
try {
|
|
445
|
+
const state = await this._readFile(this.routeStatePath(slug));
|
|
446
|
+
const saved = JSON.parse(state);
|
|
447
|
+
await this.registerRoute({ ...saved, domain, tls: true });
|
|
448
|
+
return this.ensureIssued(domain, certonlyOut);
|
|
449
|
+
} catch {
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
const existing = await this._readFile(configPath);
|
|
453
|
+
const targetMatch = existing.match(/proxy_pass\s+([^;]+);/);
|
|
454
|
+
if (targetMatch) {
|
|
455
|
+
await this.registerRoute({ domain, targetUrl: targetMatch[1], tls: true });
|
|
456
|
+
return this.ensureIssued(domain, certonlyOut);
|
|
457
|
+
}
|
|
458
|
+
const rootMatch = existing.match(/root\s+([^;]+);/);
|
|
459
|
+
if (rootMatch) {
|
|
460
|
+
await this.registerRoute({ domain, staticRoot: rootMatch[1], tls: true });
|
|
461
|
+
return this.ensureIssued(domain, certonlyOut);
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
}
|
|
465
|
+
return this.ensureIssued(domain, certonlyOut);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* After a certbot run that DIDN'T throw, confirm a usable cert actually landed
|
|
469
|
+
* at the expected path. certbot can exit 0 while producing nothing readable
|
|
470
|
+
* there (a lineage written elsewhere, "not due for renewal", 0 domains
|
|
471
|
+
* authenticated) — a silent `{missing}` hides why. Surface certbot's real
|
|
472
|
+
* output so the operator sees the actual reason instead of a generic message.
|
|
473
|
+
*/
|
|
474
|
+
async ensureIssued(domain, certbotOutput) {
|
|
475
|
+
const result = await this.readCertInfo(domain);
|
|
476
|
+
if (result.verified) return result;
|
|
477
|
+
const tail = certbotOutput.trim().slice(-1200);
|
|
478
|
+
throw new Error(
|
|
479
|
+
summarizeCertbotFailure(certbotOutput, domain) + (tail ? `
|
|
480
|
+
|
|
481
|
+
certbot exited without an error but no readable certificate is at ${join(this.certDir, domain)}. certbot said:
|
|
482
|
+
${tail}` : "")
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Renew a TLS certificate using certbot.
|
|
487
|
+
*/
|
|
488
|
+
async renewCert(domain) {
|
|
489
|
+
assertValidDomain(domain);
|
|
490
|
+
if (!await this.certsExist(domain)) {
|
|
491
|
+
return this.provisionCert(domain);
|
|
492
|
+
}
|
|
493
|
+
await this._exec("certbot", ["renew", "--cert-name", domain, "--non-interactive"]);
|
|
494
|
+
await this.reload();
|
|
495
|
+
return this.readCertInfo(domain);
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Install an operator-supplied certificate (no ACME). Validates the PEM pair,
|
|
499
|
+
* writes it to the same on-disk path certbot uses, then re-registers the vhost
|
|
500
|
+
* with TLS — mirroring provisionCert's tail so composite routes are preserved.
|
|
501
|
+
*/
|
|
502
|
+
async installCert(domain, cert) {
|
|
503
|
+
assertValidDomain(domain);
|
|
504
|
+
let expiresAt;
|
|
505
|
+
try {
|
|
506
|
+
const x509 = new X509Certificate(cert.certPem);
|
|
507
|
+
const key = createPrivateKey(cert.keyPem);
|
|
508
|
+
if (!x509.checkPrivateKey(key)) {
|
|
509
|
+
throw new Error("private key does not match certificate");
|
|
510
|
+
}
|
|
511
|
+
expiresAt = new Date(x509.validTo).toISOString();
|
|
512
|
+
} catch (err) {
|
|
513
|
+
throw new Error(`Invalid certificate: ${safeErrorMessage(err)}`);
|
|
514
|
+
}
|
|
515
|
+
const dir = join(this.certDir, domain);
|
|
516
|
+
await this._mkdir(dir);
|
|
517
|
+
await this._writeFile(join(dir, "fullchain.pem"), cert.certPem);
|
|
518
|
+
await this._writeFile(join(dir, "privkey.pem"), cert.keyPem);
|
|
519
|
+
const slug = this.domainSlug(domain);
|
|
520
|
+
try {
|
|
521
|
+
const state = await this._readFile(this.routeStatePath(slug));
|
|
522
|
+
const saved = JSON.parse(state);
|
|
523
|
+
await this.registerRoute({ ...saved, domain, tls: true });
|
|
524
|
+
} catch {
|
|
525
|
+
}
|
|
526
|
+
return { domain, expiresAt, issuer: "manual", verified: true, reason: "issued" };
|
|
527
|
+
}
|
|
528
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
529
|
+
domainSlug(domain) {
|
|
530
|
+
return domain.replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-");
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Re-detect OpenResty paths and reload.
|
|
534
|
+
*
|
|
535
|
+
* Paths can become stale after an OpenResty reinstall (the binary or
|
|
536
|
+
* config may move). Re-detecting on every reload keeps the provider
|
|
537
|
+
* in sync without requiring an API restart.
|
|
538
|
+
*/
|
|
539
|
+
async reload() {
|
|
540
|
+
if (this.executor) {
|
|
541
|
+
try {
|
|
542
|
+
const freshPaths = await detectOpenRestyPaths(this.executor);
|
|
543
|
+
this.sitesDir = freshPaths.sitesDir;
|
|
544
|
+
this.reloadCommand = buildReloadCommand(freshPaths);
|
|
545
|
+
} catch {
|
|
546
|
+
}
|
|
547
|
+
await this.executor.exec(this.reloadCommand);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
await execFileAsync("sh", ["-lc", this.reloadCommand]);
|
|
551
|
+
}
|
|
552
|
+
async certsExist(domain) {
|
|
553
|
+
const certPath = join(this.certDir, domain, "fullchain.pem");
|
|
554
|
+
if (this.executor) {
|
|
555
|
+
return this.executor.exists(certPath);
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
await fsReadFile(certPath);
|
|
559
|
+
return true;
|
|
560
|
+
} catch {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Read the on-disk cert and report its validity. Distinguishes three cases so
|
|
566
|
+
* the caller never downgrades a healthy domain on a transient hiccup:
|
|
567
|
+
* - file present + parses → verified, real expiry
|
|
568
|
+
* - file ABSENT → not verified, reason "missing" (no cert yet)
|
|
569
|
+
* - file present, unreadable/unparseable → not verified, reason "read_error"
|
|
570
|
+
*/
|
|
571
|
+
async readCertInfo(domain) {
|
|
572
|
+
if (!await this.certsExist(domain)) {
|
|
573
|
+
return { domain, expiresAt: "", issuer: "certbot", verified: false, reason: "missing" };
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
const certPath = join(this.certDir, domain, "fullchain.pem");
|
|
577
|
+
const pem = await this._readFile(certPath);
|
|
578
|
+
const { X509Certificate: X509Certificate2 } = await import("crypto");
|
|
579
|
+
const cert = new X509Certificate2(pem);
|
|
580
|
+
return {
|
|
581
|
+
domain,
|
|
582
|
+
expiresAt: new Date(cert.validTo).toISOString(),
|
|
583
|
+
issuer: "certbot",
|
|
584
|
+
verified: true
|
|
585
|
+
};
|
|
586
|
+
} catch {
|
|
587
|
+
return { domain, expiresAt: "", issuer: "certbot", verified: false, reason: "read_error" };
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Read-only check that the Let's Encrypt cert is actually present + valid on
|
|
592
|
+
* the serving host. No certbot, no config rewrite — safe to call any time
|
|
593
|
+
* (deploy, "Recheck SSL" button).
|
|
594
|
+
*/
|
|
595
|
+
async verifyCert(domain) {
|
|
596
|
+
assertValidDomain(domain);
|
|
597
|
+
return this.readCertInfo(domain);
|
|
598
|
+
}
|
|
599
|
+
// ── Rate Limiting ──────────────────────────────────────────────────
|
|
600
|
+
/** Dedicated include dir for Openship-managed OpenResty snippets. */
|
|
601
|
+
get rateLimitIncludeDir() {
|
|
602
|
+
return join(dirname(this.sitesDir), "openship-includes");
|
|
603
|
+
}
|
|
604
|
+
/** Path to the managed rate-limit snippet inside the dedicated include dir. */
|
|
605
|
+
get rateLimitConfPath() {
|
|
606
|
+
return join(this.rateLimitIncludeDir, "ratelimit.conf");
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Apply rate limit configuration to OpenResty.
|
|
610
|
+
*
|
|
611
|
+
* Writes a `ratelimit.conf` snippet with geo whitelist, map, and
|
|
612
|
+
* limit_req_zone directives. Ensures an `include` for it exists in
|
|
613
|
+
* nginx.conf's http block. Then validates + reloads.
|
|
614
|
+
*
|
|
615
|
+
* Pass rps=0 to disable rate limiting entirely.
|
|
616
|
+
*/
|
|
617
|
+
async applyRateLimit(config) {
|
|
618
|
+
const confPath = this.rateLimitConfPath;
|
|
619
|
+
const nginxConfPath = join(dirname(this.sitesDir), "nginx.conf");
|
|
620
|
+
const snapshots = {
|
|
621
|
+
nginx: await this._captureFile(nginxConfPath),
|
|
622
|
+
current: await this._captureFile(confPath)
|
|
623
|
+
};
|
|
624
|
+
const geoEntries = renderGeoEntries(config.whitelist);
|
|
625
|
+
const snippet = `# Auto-generated by Openship - rate limit config
|
|
626
|
+
# Edit via Settings > Rate Limiting in the dashboard
|
|
627
|
+
|
|
628
|
+
geo $whitelist {
|
|
629
|
+
${geoEntries.join("\n")}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
map $whitelist $limit_key {
|
|
633
|
+
1 "";
|
|
634
|
+
0 $binary_remote_addr;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
limit_req_zone $limit_key zone=global_limit:10m rate=${config.rps}r/s;
|
|
638
|
+
limit_req zone=global_limit burst=${config.burst} nodelay;
|
|
639
|
+
limit_req_status 429;
|
|
640
|
+
`;
|
|
641
|
+
try {
|
|
642
|
+
await this.ensureRateLimitInclude();
|
|
643
|
+
if (config.rps <= 0) {
|
|
644
|
+
await this._rm(confPath);
|
|
645
|
+
} else {
|
|
646
|
+
await this._mkdir(this.rateLimitIncludeDir);
|
|
647
|
+
await this._writeFile(confPath, snippet);
|
|
648
|
+
}
|
|
649
|
+
await this.reload();
|
|
650
|
+
} catch (err) {
|
|
651
|
+
let rollbackError = null;
|
|
652
|
+
try {
|
|
653
|
+
await this._restoreFile(confPath, snapshots.current);
|
|
654
|
+
await this._restoreFile(nginxConfPath, snapshots.nginx);
|
|
655
|
+
await this.reload();
|
|
656
|
+
} catch (restoreErr) {
|
|
657
|
+
rollbackError = safeErrorMessage(restoreErr);
|
|
658
|
+
}
|
|
659
|
+
const message = safeErrorMessage(err);
|
|
660
|
+
if (rollbackError) {
|
|
661
|
+
throw new Error(`${message}; rollback failed: ${rollbackError}`);
|
|
662
|
+
}
|
|
663
|
+
throw err;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Read the current rate limit config from the snippet file.
|
|
668
|
+
* Returns a disabled config when the snippet doesn't exist yet.
|
|
669
|
+
* Returns null only when the file exists but couldn't be read or parsed.
|
|
670
|
+
*/
|
|
671
|
+
async getRateLimitConfig() {
|
|
672
|
+
try {
|
|
673
|
+
const confPath = this.rateLimitConfPath;
|
|
674
|
+
if (!await this._exists(confPath)) {
|
|
675
|
+
return { rps: 0, burst: 0, whitelist: [] };
|
|
676
|
+
}
|
|
677
|
+
const content = await this._readFile(confPath);
|
|
678
|
+
if (content.includes("disabled")) return { rps: 0, burst: 0, whitelist: [] };
|
|
679
|
+
const rpsMatch = content.match(/rate=(\d+)r\/s/);
|
|
680
|
+
const rps = rpsMatch ? parseInt(rpsMatch[1], 10) : 50;
|
|
681
|
+
const burstMatch = content.match(/burst=(\d+)/);
|
|
682
|
+
const burst = burstMatch ? parseInt(burstMatch[1], 10) : 20;
|
|
683
|
+
const whitelist = [];
|
|
684
|
+
const geoBlock = content.match(/geo \$whitelist \{([\s\S]*?)\}/);
|
|
685
|
+
if (geoBlock) {
|
|
686
|
+
const lines = geoBlock[1].split("\n");
|
|
687
|
+
for (const line of lines) {
|
|
688
|
+
const m = line.match(/^\s+([\da-fA-F.:\/]+)\s+1;/);
|
|
689
|
+
if (m && !["127.0.0.1/32", "::1/128"].includes(m[1]) && m[1] !== "default") {
|
|
690
|
+
whitelist.push(m[1]);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return { rps, burst, whitelist };
|
|
695
|
+
} catch {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Ensure nginx.conf includes the rate limit snippet in its http block.
|
|
701
|
+
*/
|
|
702
|
+
async ensureRateLimitInclude() {
|
|
703
|
+
const confDir = dirname(this.sitesDir);
|
|
704
|
+
const confPath = join(confDir, "nginx.conf");
|
|
705
|
+
const desiredIncludeLine = `include ${this.rateLimitIncludeDir}/*.conf;`;
|
|
706
|
+
const content = await this._readFile(confPath);
|
|
707
|
+
const trailingNewline = content.endsWith("\n");
|
|
708
|
+
const lines = content.split("\n");
|
|
709
|
+
const nextLines = [];
|
|
710
|
+
let foundDesiredInclude = false;
|
|
711
|
+
let changed = false;
|
|
712
|
+
await this._mkdir(this.rateLimitIncludeDir);
|
|
713
|
+
for (const line of lines) {
|
|
714
|
+
const trimmed = line.trim();
|
|
715
|
+
if (trimmed === desiredIncludeLine) {
|
|
716
|
+
if (foundDesiredInclude) {
|
|
717
|
+
changed = true;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
foundDesiredInclude = true;
|
|
721
|
+
}
|
|
722
|
+
nextLines.push(line);
|
|
723
|
+
}
|
|
724
|
+
if (!foundDesiredInclude) {
|
|
725
|
+
const httpIndex = nextLines.findIndex((line) => /^\s*http\s*\{\s*$/.test(line));
|
|
726
|
+
if (httpIndex === -1) {
|
|
727
|
+
throw new Error(`Failed to ensure rate-limit include: ${confPath} is missing an http block`);
|
|
728
|
+
}
|
|
729
|
+
const indent = nextLines[httpIndex].match(/^\s*/)?.[0] ?? "";
|
|
730
|
+
nextLines.splice(httpIndex + 1, 0, `${indent} ${desiredIncludeLine}`);
|
|
731
|
+
changed = true;
|
|
732
|
+
}
|
|
733
|
+
if (changed) {
|
|
734
|
+
const nextContent = nextLines.join("\n");
|
|
735
|
+
await this._writeFile(confPath, trailingNewline ? `${nextContent}
|
|
736
|
+
` : nextContent);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
export {
|
|
742
|
+
renderProxyOptions,
|
|
743
|
+
renderGeoEntries,
|
|
744
|
+
summarizeCertbotFailure,
|
|
745
|
+
NginxProvider
|
|
746
|
+
};
|