openship 0.4.8 → 0.6.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.
Files changed (112) hide show
  1. package/README.md +27 -1
  2. package/dist/bare-FSUAM5V5.js +23 -0
  3. package/dist/{bowser-O3AEA6AN.js → bowser-HNOTPGJO.js} +2 -2
  4. package/dist/{chunk-5MZ624VX.js → chunk-2GWYYHOR.js} +1 -1
  5. package/dist/chunk-3FYIXVX5.js +8051 -0
  6. package/dist/{chunk-ILFETZOM.js → chunk-3LDWWY7U.js} +21 -1
  7. package/dist/{chunk-W73X43R7.js → chunk-3NQQXMDR.js} +3 -3
  8. package/dist/{chunk-OWED2GRM.js → chunk-42WQABXM.js} +2 -2
  9. package/dist/{chunk-TYVME3XD.js → chunk-6FH4AXVY.js} +20 -3
  10. package/dist/{chunk-OXWDY22G.js → chunk-6ODUBKUB.js} +3 -3
  11. package/dist/{chunk-DYPH4HCI.js → chunk-AO252K7Z.js} +1 -1
  12. package/dist/{chunk-XPPZ7B2J.js → chunk-B3CE2OMR.js} +14 -12
  13. package/dist/{chunk-IXCHURVK.js → chunk-DOD2BXHQ.js} +4 -4
  14. package/dist/{chunk-VWPEKOH6.js → chunk-DRK3MEDS.js} +3 -3
  15. package/dist/{chunk-V6AN6XLZ.js → chunk-EMXI4UCU.js} +125 -47
  16. package/dist/{chunk-GKHUSZCD.js → chunk-EP2TNALB.js} +2 -2
  17. package/dist/{chunk-SLTYVHU7.js → chunk-GUQ3QCJ7.js} +942 -519
  18. package/dist/chunk-I3DPMTSF.js +29 -0
  19. package/dist/{chunk-L2VOGB2X.js → chunk-IW2UKBVF.js} +3 -3
  20. package/dist/{chunk-VDMQJHPI.js → chunk-IXM5245F.js} +228 -13
  21. package/dist/{chunk-MZ2WEHHY.js → chunk-JIMUYEKN.js} +4 -4
  22. package/dist/chunk-OGY63DGD.js +705 -0
  23. package/dist/{chunk-O6ZZF52V.js → chunk-ONKEYSMY.js} +2 -2
  24. package/dist/{chunk-6APULWSU.js → chunk-OTLSJYAC.js} +1744 -118
  25. package/dist/{chunk-EI575GHN.js → chunk-PDW4L3NV.js} +3 -3
  26. package/dist/{chunk-5NYUCONN.js → chunk-PSZSKEPA.js} +33 -51
  27. package/dist/{chunk-KI2EY3WB.js → chunk-PXKCZB2I.js} +1 -1
  28. package/dist/{chunk-FXTBHTWY.js → chunk-QEH4QUBX.js} +649 -85
  29. package/dist/{chunk-6APIPIJY.js → chunk-QWHBRY2K.js} +5 -5
  30. package/dist/{chunk-6B5YCFFV.js → chunk-RVER2EOO.js} +5 -5
  31. package/dist/{chunk-Q2CIRAOK.js → chunk-RXQBD4ZY.js} +1 -1
  32. package/dist/{chunk-TGIINPVM.js → chunk-TWJCLD4W.js} +60 -10
  33. package/dist/{chunk-4Z5QO7N6.js → chunk-VDDMADOF.js} +3 -3
  34. package/dist/{chunk-BBHX7T4R.js → chunk-VE2YOK4I.js} +991 -254
  35. package/dist/{chunk-2D3I5G6P.js → chunk-VZ2ZHPS2.js} +73 -43
  36. package/dist/{chunk-AVIB7CT7.js → chunk-WNWQBB7F.js} +2 -2
  37. package/dist/{chunk-FRNAR33O.js → chunk-XZYFZ6A5.js} +3 -3
  38. package/dist/{chunk-RPHRPFEH.js → chunk-ZCFVHA25.js} +1 -1
  39. package/dist/{chunk-FPRHYBY2.js → chunk-ZFJXPCQP.js} +1 -1
  40. package/dist/{chunk-JYGD5WSN.js → chunk-ZWLDLTJP.js} +1 -1
  41. package/dist/cloud-E5M6VMCG.js +31 -0
  42. package/dist/{cloud-7QYOGF7F.js → cloud-KRTZ2IL5.js} +3 -3
  43. package/dist/{detect-AZLOHUFX.js → detect-ALF4GXRX.js} +7 -8
  44. package/dist/{dist-IQJ6H6UZ.js → dist-BM6NPWIH.js} +3 -3
  45. package/dist/{dist-es-X3B5QYKB.js → dist-es-2YRDXMUU.js} +6 -6
  46. package/dist/{dist-es-OSNJBLXB.js → dist-es-CCVZEFRZ.js} +5 -5
  47. package/dist/{dist-es-VNKHQVIB.js → dist-es-IOH6G4GM.js} +4 -4
  48. package/dist/{dist-es-RYHIPOTN.js → dist-es-JGBPABJI.js} +14 -14
  49. package/dist/{dist-es-JSTDUS7Q.js → dist-es-KWSMDPIP.js} +6 -6
  50. package/dist/{dist-es-5DY7K32L.js → dist-es-UYDDOVZ3.js} +6 -6
  51. package/dist/{dist-es-WV4AMOII.js → dist-es-XUOR4NQN.js} +8 -8
  52. package/dist/docker-QECQJ5P4.js +32 -0
  53. package/dist/docker-edge-executor-62N64MGS.js +17 -0
  54. package/dist/edge-import-CQWHN2Q3.js +48 -0
  55. package/dist/ensure-container-edge-MJVXJQG5.js +32 -0
  56. package/dist/{event-streams-EHRAKL75.js → event-streams-TR4GAC53.js} +4 -4
  57. package/dist/{executor-XJ6RP4Q2.js → executor-JELARVKC.js} +8 -8
  58. package/dist/index.js +1209 -560
  59. package/dist/{loadSso-46FQRIZS.js → loadSso-UGF4JMRK.js} +10 -10
  60. package/dist/{local-executor-V7U5VOZW.js → local-executor-WOFZD72X.js} +4 -4
  61. package/dist/nginx-AXGNWRKZ.js +31 -0
  62. package/dist/node-entry.js +4 -0
  63. package/dist/{noop-FMMFG2EZ.js → noop-NJ72F46T.js} +3 -3
  64. package/dist/openresty-lua-IE4P5YQX.js +81 -0
  65. package/dist/server/index.js +116248 -94749
  66. package/dist/server/lua/geo_country.lua +31 -6
  67. package/dist/server/lua/maxminddb.lua +403 -0
  68. package/dist/server/lua/mgmt_api.lua +431 -68
  69. package/dist/server/lua/pipe_log.lua +10 -4
  70. package/dist/server/lua/site_logger.lua +220 -12
  71. package/dist/server/migrations/0073_project_volumes_object_storage.sql +3 -0
  72. package/dist/server/migrations/0074_project_compose_path.sql +1 -0
  73. package/dist/server/migrations/0075_service_command_argv.sql +1 -0
  74. package/dist/server/migrations/0076_rollback_retention.sql +17 -0
  75. package/dist/server/migrations/0077_domain_redirect.sql +13 -0
  76. package/dist/server/migrations/0078_grant_source_scope.sql +30 -0
  77. package/dist/server/migrations/0079_project_readiness.sql +18 -0
  78. package/dist/server/migrations/0080_deployment_error_classification.sql +26 -0
  79. package/dist/server/migrations/0081_project_readiness_repair.sql +34 -0
  80. package/dist/server/migrations/0082_edge_target_verification.sql +39 -0
  81. package/dist/server/migrations/0083_service_incident.sql +84 -0
  82. package/dist/server/migrations/0084_analytics_daily_rollup.sql +72 -0
  83. package/dist/server/migrations/0085_resource_usage.sql +68 -0
  84. package/dist/server/migrations/0086_remote_infra_updates.sql +41 -0
  85. package/dist/server/migrations/0087_server_container_version_columns.sql +17 -0
  86. package/dist/server/migrations/0088_audit_source_and_settings.sql +27 -0
  87. package/dist/server/migrations/0089_auto_scan_infra.sql +7 -0
  88. package/dist/server/migrations/0090_project_collect_paths.sql +18 -0
  89. package/dist/server/migrations/0091_project_server_id.sql +26 -0
  90. package/dist/server/migrations/0092_project_internal_alias.sql +13 -0
  91. package/dist/server/migrations/0093_service_incident_org_index.sql +13 -0
  92. package/dist/server/migrations/0094_backup_restore_meta.sql +15 -0
  93. package/dist/server/migrations/0095_backup_restore_cancel.sql +19 -0
  94. package/dist/server/migrations/0096_backup_policy_retention_defaults.sql +20 -0
  95. package/dist/server/migrations/0097_update_status_upstream_only.sql +35 -0
  96. package/dist/server/migrations/meta/_journal.json +175 -0
  97. package/dist/setup-FA4PW4NU.js +21 -0
  98. package/dist/{signin-ITOPKKUB.js → signin-3M3UPB6K.js} +10 -10
  99. package/dist/{sso-oidc-NON2PD7Y.js → sso-oidc-3VDIYOEJ.js} +10 -10
  100. package/dist/{sts-HHFFSAY2.js → sts-B3WIYLXR.js} +9 -9
  101. package/package.json +6 -3
  102. package/dist/bare-JY5Y5ELU.js +0 -23
  103. package/dist/chunk-SJIFLLX4.js +0 -2931
  104. package/dist/chunk-VFINNTUL.js +0 -362
  105. package/dist/cloud-KYWY3RP4.js +0 -30
  106. package/dist/docker-LODK5Y2S.js +0 -16
  107. package/dist/docker-edge-executor-VHW3JPJS.js +0 -13
  108. package/dist/edge-import-ABUDKKJI.js +0 -45
  109. package/dist/ensure-container-edge-SW6HENSG.js +0 -31
  110. package/dist/nginx-3L4SIBJA.js +0 -24
  111. package/dist/openresty-lua-4WLGDSER.js +0 -42
  112. package/dist/setup-NU6T2PDU.js +0 -20
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env sh
2
- ":" //# ; exec "$(command -v bun || command -v node)" "$0" "$@"
2
+ ":" //# ; exec "$(command -v node || command -v bun)" "$0" "$@"
3
3
  import { createRequire as __ospCreateRequire } from "node:module";
4
4
  const require = __ospCreateRequire(import.meta.url);
5
5
  import {
6
6
  resolveEnvironment
7
- } from "./chunk-L2VOGB2X.js";
7
+ } from "./chunk-IW2UKBVF.js";
8
8
  import {
9
9
  formatDuration,
10
10
  isRemoteConnectionError,
11
11
  systemDebug
12
- } from "./chunk-RPHRPFEH.js";
12
+ } from "./chunk-ZCFVHA25.js";
13
13
  import {
14
14
  containerCommand,
15
15
  edgeContainerExecutor,
@@ -17,41 +17,250 @@ import {
17
17
  readDeclaredPair,
18
18
  readMaybeInContainer,
19
19
  validateCertFor
20
- } from "./chunk-FXTBHTWY.js";
21
- import {
22
- sq
23
- } from "./chunk-FPRHYBY2.js";
20
+ } from "./chunk-QEH4QUBX.js";
24
21
  import {
25
22
  EDGE_CONTAINER_NAME,
26
23
  EdgeConflictError,
27
24
  EdgeMigrateRequested,
25
+ edgeCrashReason,
28
26
  edgeFailureReason,
27
+ edgeIsBroken,
29
28
  freeEdgeTargets,
30
29
  invalidateEdgeContainer,
31
- ourLuaOnHost,
32
30
  probeEdge,
33
31
  resolveOurEdgeContainer,
34
32
  sanitizeEdgeVhosts,
35
33
  sq as sq2,
36
34
  stopTargetsForStatus
37
- } from "./chunk-2D3I5G6P.js";
35
+ } from "./chunk-VZ2ZHPS2.js";
38
36
  import {
39
37
  waitForPortListening
40
- } from "./chunk-ILFETZOM.js";
38
+ } from "./chunk-3LDWWY7U.js";
41
39
  import {
40
+ EDGE_CHALLENGE_HOST_DIR,
42
41
  EDGE_CONTAINER_MOUNTS,
43
42
  EDGE_HOST_PATHS,
44
- OPENRESTY_DEFAULT_PATHS,
45
- detectOpenRestyPaths
46
- } from "./chunk-VFINNTUL.js";
43
+ OPENRESTY_DEFAULT_PATHS
44
+ } from "./chunk-OGY63DGD.js";
47
45
  import {
46
+ sq
47
+ } from "./chunk-ZFJXPCQP.js";
48
+ import {
49
+ AppError,
50
+ PROXY_DIRECTIVES,
48
51
  buildEdgeImageRef,
49
52
  compareSemver,
50
- safeErrorMessage
51
- } from "./chunk-6APULWSU.js";
53
+ parseProxyValue,
54
+ safeErrorMessage,
55
+ sanitizeProxySettings
56
+ } from "./chunk-OTLSJYAC.js";
57
+
58
+ // ../../packages/adapters/src/system/managed-image.ts
59
+ import { posix } from "path";
60
+ function log(message, level = "info") {
61
+ return { timestamp: (/* @__PURE__ */ new Date()).toISOString(), message, level };
62
+ }
63
+ function titleCase(label) {
64
+ return label.charAt(0).toUpperCase() + label.slice(1);
65
+ }
66
+ var fromSourceManaged = { edge: false, mail: false };
67
+ function managedImagesAreFromSource(kind) {
68
+ return fromSourceManaged[kind];
69
+ }
70
+ function fromSourceImageMissingMessage(label, image) {
71
+ return `The from-source ${label} image ${image} isn't on this server \u2014 the control-plane build or transfer didn't complete (see the log above). It's an unpublished dev tag, so there's nothing to pull; fix the build and retry.`;
72
+ }
73
+ async function dockerAvailable(executor) {
74
+ return executor.exec("docker version --format '{{.Server.Version}}' 2>/dev/null").then((v) => Boolean(v.trim())).catch(() => false);
75
+ }
76
+ async function containerState(executor, container) {
77
+ const out = await executor.exec(`docker inspect -f '{{.State.Running}} {{.Config.Image}}' ${sq(container)} 2>/dev/null`).catch(() => "");
78
+ const line = out.split("\n").map((l) => l.trim()).find(Boolean);
79
+ if (!line) return null;
80
+ const [running, image] = line.split(" ");
81
+ return { running: running?.trim() === "true", image: image?.trim() || null };
82
+ }
83
+ async function containerImageRef(executor, container) {
84
+ return (await containerState(executor, container))?.image ?? null;
85
+ }
86
+ async function imageExistsLocally(executor, ref) {
87
+ const out = await executor.exec(`docker image inspect -f '{{.Id}}' ${sq(ref)} 2>/dev/null`).catch(() => "");
88
+ return Boolean(out.trim());
89
+ }
90
+ async function swapManagedImage(executor, opts) {
91
+ const { kind, from, to, label, onLog, start } = opts;
92
+ const Label = titleCase(label);
93
+ onLog(log(`Updating the ${label}: ${from} \u2192 ${to}`));
94
+ if (!await imageExistsLocally(executor, to)) {
95
+ if (managedImagesAreFromSource(kind)) {
96
+ onLog(log(fromSourceImageMissingMessage(label, to), "warn"));
97
+ return { swapped: false, down: false };
98
+ }
99
+ const pull = await executor.streamExec(`docker pull ${sq(to)}`, onLog);
100
+ if (pull.code !== 0) {
101
+ onLog(log(`Could not pull ${to} \u2014 the ${label} stays on ${from}.`, "warn"));
102
+ return { swapped: false, down: false };
103
+ }
104
+ }
105
+ if (await start(to)) {
106
+ onLog(log(`${Label} updated to ${to}`));
107
+ return { swapped: true, down: false };
108
+ }
109
+ onLog(log(`${Label} ${to} failed to come up \u2014 rolling back to ${from}...`, "error"));
110
+ if (await start(from)) {
111
+ return { swapped: false, down: false };
112
+ }
113
+ onLog(log(`Rollback to ${from} ALSO failed \u2014 the ${label} is down on this server.`, "error"));
114
+ return { swapped: false, down: true };
115
+ }
116
+
117
+ // ../../packages/adapters/src/system/proxy/takeover-journal.ts
118
+ var JOURNAL_DIR = "/var/lib/openship";
119
+ var JOURNAL_PATH = `${JOURNAL_DIR}/edge-takeover.json`;
120
+ function log2(message, level = "info") {
121
+ return { timestamp: (/* @__PURE__ */ new Date()).toISOString(), message, level };
122
+ }
123
+ async function tryExec(executor, cmd) {
124
+ try {
125
+ return await executor.exec(cmd);
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+ async function buildJournal(executor, status) {
131
+ const units = /* @__PURE__ */ new Map();
132
+ const containers = /* @__PURE__ */ new Map();
133
+ const processes = /* @__PURE__ */ new Map();
134
+ for (const o of status.occupants) {
135
+ if (o.containerName && !containers.has(o.containerName)) {
136
+ const r = await tryExec(
137
+ executor,
138
+ `docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' ${sq2(o.containerName)} 2>/dev/null`
139
+ );
140
+ containers.set(o.containerName, { name: o.containerName, restart: r?.trim() || "no" });
141
+ } else if (o.systemdUnit && !units.has(o.systemdUnit)) {
142
+ const en = await tryExec(executor, `systemctl is-enabled ${sq2(o.systemdUnit)} 2>/dev/null`);
143
+ units.set(o.systemdUnit, { unit: o.systemdUnit, wasEnabled: en?.trim() === "enabled" });
144
+ } else if (!o.containerName && !o.systemdUnit && o.pid && !processes.has(o.pid)) {
145
+ processes.set(o.pid, { pid: o.pid, command: o.rawCommand });
146
+ }
147
+ }
148
+ return {
149
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
150
+ units: [...units.values()],
151
+ containers: [...containers.values()],
152
+ processes: [...processes.values()]
153
+ };
154
+ }
155
+ async function writeJournal(executor, journal) {
156
+ try {
157
+ await executor.mkdir(JOURNAL_DIR);
158
+ await executor.writeFile(JOURNAL_PATH, JSON.stringify(journal, null, 2));
159
+ } catch {
160
+ }
161
+ }
162
+ async function clearJournal(executor) {
163
+ await tryExec(executor, `rm -f ${JOURNAL_PATH}`);
164
+ }
165
+ async function readJournal(executor) {
166
+ const raw = await tryExec(executor, `cat ${JOURNAL_PATH} 2>/dev/null`);
167
+ if (!raw?.trim()) return null;
168
+ try {
169
+ return JSON.parse(raw);
170
+ } catch {
171
+ await clearJournal(executor);
172
+ return null;
173
+ }
174
+ }
175
+ async function rollback(executor, journal, onLog) {
176
+ onLog(log2("Rolling back \u2014 restoring the previous proxy...", "warn"));
177
+ await tryExec(executor, `docker update --restart=no ${sq2(EDGE_CONTAINER_NAME)} 2>/dev/null || true`);
178
+ await tryExec(executor, `docker stop ${sq2(EDGE_CONTAINER_NAME)} 2>/dev/null || true`);
179
+ await tryExec(
180
+ executor,
181
+ "systemctl disable --now openresty 2>/dev/null || systemctl stop openresty 2>/dev/null || true; systemctl reset-failed openresty 2>/dev/null || true"
182
+ );
183
+ for (const u of journal.units) {
184
+ await tryExec(
185
+ executor,
186
+ u.wasEnabled ? `systemctl enable --now ${sq2(u.unit)} 2>/dev/null || true` : `systemctl start ${sq2(u.unit)} 2>/dev/null || true`
187
+ );
188
+ }
189
+ for (const c of journal.containers) {
190
+ await tryExec(executor, `docker update --restart=${sq2(c.restart)} ${sq2(c.name)} 2>/dev/null || true`);
191
+ await tryExec(executor, `docker start ${sq2(c.name)} 2>/dev/null || true`);
192
+ }
193
+ for (const p of journal.processes ?? []) {
194
+ if (p.command) {
195
+ await tryExec(
196
+ executor,
197
+ `setsid -f sh -c ${sq2(p.command)} 2>/dev/null || (nohup sh -c ${sq2(p.command)} >/dev/null 2>&1 &) || true`
198
+ );
199
+ } else {
200
+ onLog(log2(`Could not restore process ${p.pid} \u2014 no command captured.`, "warn"));
201
+ }
202
+ }
203
+ const restored = await portIsServed(executor, 80);
204
+ if (!restored) {
205
+ onLog(
206
+ log2(
207
+ "Nothing is listening on :80 after the restore \u2014 the box is NOT serving. Start your proxy by hand (e.g. `systemctl start nginx`, or `docker start <name>`).",
208
+ "error"
209
+ )
210
+ );
211
+ }
212
+ return restored;
213
+ }
214
+ async function portIsServed(executor, port) {
215
+ const hex = port.toString(16).toUpperCase().padStart(4, "0");
216
+ const out = await tryExec(
217
+ executor,
218
+ `(command -v ss >/dev/null 2>&1 && ss -ltn 2>/dev/null | grep -qE ':${port}[[:space:]]' && echo yes) || (grep -qiE '^[[:space:]]*[0-9]+:[[:space:]]*[0-9A-F]{8}:${hex}[[:space:]]+[0-9A-F]{8}:0000[[:space:]]+0A' /proc/net/tcp 2>/dev/null && echo yes) || true`
219
+ );
220
+ return (out ?? "").includes("yes");
221
+ }
222
+ async function beginEdgeTakeover(executor, status, onLog, targets) {
223
+ await writeJournal(executor, await buildJournal(executor, status));
224
+ return freeEdgeTargets(
225
+ executor,
226
+ targets?.length ? targets : stopTargetsForStatus(status),
227
+ (m, l) => onLog(log2(m, l))
228
+ );
229
+ }
230
+ async function rollbackEdgeTakeover(executor, onLog) {
231
+ const journal = await readJournal(executor);
232
+ if (!journal || journal.completed) return false;
233
+ const restored = await rollback(executor, journal, onLog);
234
+ await clearJournal(executor);
235
+ return restored;
236
+ }
237
+ async function completeEdgeTakeover(executor) {
238
+ const journal = await readJournal(executor);
239
+ if (journal) {
240
+ journal.completed = true;
241
+ await writeJournal(executor, journal);
242
+ }
243
+ await clearJournal(executor);
244
+ }
245
+ async function recoverInterruptedTakeover(executor, onLog, isEdgeHealthy) {
246
+ const journal = await readJournal(executor);
247
+ if (!journal) return;
248
+ if (journal.completed) {
249
+ await clearJournal(executor);
250
+ return;
251
+ }
252
+ if (isEdgeHealthy && await isEdgeHealthy()) {
253
+ onLog(log2("Found an unfinished edge takeover, but our edge is serving \u2014 keeping it.", "warn"));
254
+ await clearJournal(executor);
255
+ return;
256
+ }
257
+ onLog(log2("Found an interrupted edge takeover \u2014 restoring the previous proxy.", "warn"));
258
+ await rollback(executor, journal, onLog);
259
+ await clearJournal(executor);
260
+ }
52
261
 
53
262
  // ../../packages/adapters/src/system/proxy/import/parse-utils.ts
54
- async function tryExec(executor, command) {
263
+ async function tryExec2(executor, command) {
55
264
  try {
56
265
  return await executor.exec(command);
57
266
  } catch {
@@ -59,7 +268,21 @@ async function tryExec(executor, command) {
59
268
  }
60
269
  }
61
270
  function stripComments(text) {
62
- return text.replace(/#.*$/gm, "");
271
+ return text.split("\n").map((line) => {
272
+ let quote;
273
+ for (let i = 0; i < line.length; i++) {
274
+ const ch = line[i];
275
+ if (quote) {
276
+ if (ch === "\\") i++;
277
+ else if (ch === quote) quote = void 0;
278
+ } else if (ch === '"' || ch === "'") {
279
+ quote = ch;
280
+ } else if (ch === "#") {
281
+ return line.slice(0, i);
282
+ }
283
+ }
284
+ return line;
285
+ }).join("\n");
63
286
  }
64
287
  function extractBlocks(text, keyword) {
65
288
  const blocks = [];
@@ -82,15 +305,40 @@ function extractBlocks(text, keyword) {
82
305
  }
83
306
  return blocks;
84
307
  }
308
+ function collapseByHost(items, hostsOf, score) {
309
+ const groups = /* @__PURE__ */ new Map();
310
+ for (const item of items) {
311
+ const key = [...hostsOf(item)].sort().join(" ");
312
+ const bag = groups.get(key);
313
+ if (bag) bag.push(item);
314
+ else groups.set(key, [item]);
315
+ }
316
+ const kept = [];
317
+ const dropped = [];
318
+ for (const bag of groups.values()) {
319
+ let winner = bag[0];
320
+ for (const cand of bag) if (score(cand) > score(winner)) winner = cand;
321
+ kept.push(winner);
322
+ for (const loser of bag) if (loser !== winner) dropped.push(loser);
323
+ }
324
+ return { kept, dropped };
325
+ }
85
326
  function sq3(value) {
86
327
  return `'${value.replace(/'/g, "'\\''")}'`;
87
328
  }
88
329
 
89
330
  // ../../packages/adapters/src/system/proxy/import/nginx.ts
331
+ async function dumpResolvedConfig(executor, bins) {
332
+ for (const bin of bins) {
333
+ const dumped = await tryExec2(executor, `${bin} -T 2>/dev/null`);
334
+ if (dumped && /server\s*\{/.test(dumped)) return dumped;
335
+ }
336
+ return null;
337
+ }
90
338
  async function loadNginxConfig(executor) {
91
- const dumped = await tryExec(executor, "nginx -T 2>/dev/null");
92
- if (dumped && /server\s*\{/.test(dumped)) return dumped;
93
- const cat = await tryExec(
339
+ const dumped = await dumpResolvedConfig(executor, ["nginx", "openresty"]);
340
+ if (dumped) return dumped;
341
+ const cat = await tryExec2(
94
342
  executor,
95
343
  "cat /etc/nginx/nginx.conf /etc/nginx/sites-enabled/* /etc/nginx/conf.d/*.conf 2>/dev/null"
96
344
  );
@@ -122,10 +370,13 @@ function resolveProxyTarget(proxyPass, upstreams) {
122
370
  const host = authority.replace(/:\d+$/, "");
123
371
  if (upstreams.has(host)) return { url: `${scheme}${upstreams.get(host)}` };
124
372
  const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
125
- if (isIp || host === "localhost" || host.includes(".")) return { url: raw };
373
+ if (host === "localhost" || host === "ip6-localhost") {
374
+ return { url: raw.replace(/^(https?:\/\/)[^/:]+/i, "$1127.0.0.1") };
375
+ }
376
+ if (isIp || host.includes(".")) return { url: raw };
126
377
  return { reason: `proxy_pass host "${host}" is an undeclared upstream \u2014 not migratable` };
127
378
  }
128
- function extractLocationProxies(serverBody) {
379
+ function locationBlocks(serverBody) {
129
380
  const out = [];
130
381
  const re = /(?:^|[\s;}])location\s+([^{]+?)\s*\{/g;
131
382
  let m;
@@ -139,13 +390,26 @@ function extractLocationProxies(serverBody) {
139
390
  else if (serverBody[i] === "}") depth--;
140
391
  }
141
392
  if (depth !== 0) break;
142
- const locBody = serverBody.slice(openIdx + 1, i - 1);
393
+ out.push({ path, body: serverBody.slice(openIdx + 1, i - 1) });
143
394
  re.lastIndex = i;
144
- const pp = firstDirective(locBody, "proxy_pass");
145
- if (pp) out.push({ path, proxyPass: pp });
146
395
  }
147
396
  return out;
148
397
  }
398
+ function extractLocationProxies(serverBody) {
399
+ const out = [];
400
+ for (const loc of locationBlocks(serverBody)) {
401
+ const pp = firstDirective(loc.body, "proxy_pass");
402
+ if (pp) out.push({ path: loc.path, proxyPass: pp });
403
+ }
404
+ return out;
405
+ }
406
+ function withoutQuotedValues(body) {
407
+ return body.replace(/'[^'\n]*'/g, "''").replace(/"[^"\n]*"/g, '""');
408
+ }
409
+ var SERVING_DIRECTIVE_RE = /(?:^|[;{}\s])(?:proxy_pass|root|alias|fastcgi_pass|uwsgi_pass|scgi_pass|grpc_pass)\s/;
410
+ function servesNothing(locationBody) {
411
+ return !SERVING_DIRECTIVE_RE.test(withoutQuotedValues(locationBody));
412
+ }
149
413
  function isHttpsUpgradeForSelf(body, names) {
150
414
  const targets = [
151
415
  ...body.matchAll(/(?:^|[\s;{])return\s+30[1-8]\s+([^;]+);/g),
@@ -160,6 +424,32 @@ function isHttpsUpgradeForSelf(body, names) {
160
424
  return names.includes(host);
161
425
  });
162
426
  }
427
+ var CHALLENGE_LOCATION_RE = /\.well-known\/(acme-challenge|oblien-proxy-challenge)/i;
428
+ function isAcmeWebrootOnly(body) {
429
+ const locations = locationBlocks(body);
430
+ if (locations.length === 0) {
431
+ const root = firstDirective(body, "root") ?? "";
432
+ return /letsencrypt|acme|certbot/i.test(root);
433
+ }
434
+ if (!locations.some((l) => CHALLENGE_LOCATION_RE.test(l.path))) return false;
435
+ return locations.every((l) => CHALLENGE_LOCATION_RE.test(l.path) || servesNothing(l.body));
436
+ }
437
+ function parseProxyDirectives(body) {
438
+ const raw = {};
439
+ const candidate = {};
440
+ for (const spec of PROXY_DIRECTIVES) {
441
+ const found = firstDirective(body, spec.directive);
442
+ if (found === void 0) continue;
443
+ raw[spec.key] = found;
444
+ const parsed = parseProxyValue(spec, found);
445
+ if (parsed !== void 0) candidate[spec.key] = parsed;
446
+ }
447
+ const settings = sanitizeProxySettings(candidate);
448
+ return {
449
+ ...settings ? { settings } : {},
450
+ ...Object.keys(raw).length > 0 ? { raw } : {}
451
+ };
452
+ }
163
453
  function parseServer(body, source, upstreams) {
164
454
  const warnings = [];
165
455
  const names = firstDirective(body, "server_name")?.split(/\s+/).filter((n) => n && n !== "_" && !n.startsWith("~")) ?? [];
@@ -186,10 +476,12 @@ function parseServer(body, source, upstreams) {
186
476
  const primary = resolved.find((r) => r.path === "/") ?? resolved[0];
187
477
  target = { kind: "proxy", url: primary.url };
188
478
  routes = resolved;
189
- } else if (root) {
190
- target = { kind: "static", root: root.replace(/;$/, "") };
191
479
  } else if (isHttpsUpgradeForSelf(body, names)) {
192
480
  return { warnings: [] };
481
+ } else if (root && !isAcmeWebrootOnly(body)) {
482
+ target = { kind: "static", root: root.replace(/;$/, "") };
483
+ } else if (root) {
484
+ return { warnings: [] };
193
485
  } else {
194
486
  warnings.push(`nginx: ${names[0]} has neither proxy_pass nor root \u2014 skipped (${source})`);
195
487
  return { warnings };
@@ -197,6 +489,9 @@ function parseServer(body, source, upstreams) {
197
489
  const site = { serverNames: names, ssl, target, source };
198
490
  if (routes) site.routes = routes;
199
491
  if (certPath && keyPath) site.tls = { certPath, keyPath };
492
+ const tunables = parseProxyDirectives(body);
493
+ if (tunables.settings) site.proxy = tunables.settings;
494
+ if (tunables.raw) site.proxyRaw = tunables.raw;
200
495
  return { site, warnings };
201
496
  }
202
497
  function parseNginxConfig(raw) {
@@ -216,7 +511,12 @@ function parseNginxConfig(raw) {
216
511
  warnings.push(...blockWarnings);
217
512
  if (site) sites.push(site);
218
513
  }
219
- return { proxy: "nginx", sites, warnings };
514
+ const { kept } = collapseByHost(
515
+ sites,
516
+ (s) => s.serverNames,
517
+ (s) => (s.ssl ? 2 : 0) + (s.target.kind === "proxy" ? 1 : 0)
518
+ );
519
+ return { proxy: "nginx", sites: kept, warnings };
220
520
  }
221
521
  async function scanNginx(executor) {
222
522
  return parseNginxConfig(await loadNginxConfig(executor));
@@ -226,42 +526,90 @@ var OUR_EDGE_SITE_GLOBS = [
226
526
  `${OPENRESTY_DEFAULT_PATHS.sitesDir}/*.conf`,
227
527
  "/etc/openresty/sites-enabled/*.conf"
228
528
  ];
529
+ async function scanForeignOpenResty(executor) {
530
+ const dump = await dumpResolvedConfig(executor, ["openresty"]);
531
+ if (dump) return parseNginxConfig(dump);
532
+ return scanOpenshipEdge(executor);
533
+ }
229
534
  async function scanOpenshipEdge(executor) {
230
535
  const cat = `cat ${OUR_EDGE_SITE_GLOBS.join(" ")} 2>/dev/null`;
231
- const raw = await tryExec(executor, cat);
536
+ const raw = await tryExec2(executor, cat);
232
537
  if (raw?.trim()) return parseNginxConfig(raw);
233
538
  const container = await resolveOurEdgeContainer(executor);
234
539
  if (!container) return parseNginxConfig("");
235
- const fromContainer = await tryExec(executor, containerCommand(container, cat));
540
+ const fromContainer = await tryExec2(executor, containerCommand(container, cat));
236
541
  return parseNginxConfig(fromContainer ?? "");
237
542
  }
238
543
 
239
544
  // ../../packages/adapters/src/system/proxy/import/caddy.ts
240
545
  var CADDYFILE_PATHS = ["/etc/caddy/Caddyfile", "/etc/Caddyfile"];
546
+ async function loadCaddyAdminConfig(executor) {
547
+ const endpoint = "http://localhost:2019/config/";
548
+ const out = await tryExec2(executor, `curl -fsS ${endpoint} 2>/dev/null`) || await tryExec2(executor, `wget -qO- ${endpoint} 2>/dev/null`);
549
+ return out && out.trim().startsWith("{") ? out : null;
550
+ }
241
551
  async function loadCaddyJson(executor) {
242
552
  for (const p of CADDYFILE_PATHS) {
243
- const out = await tryExec(executor, `caddy adapt --adapter caddyfile --config ${p} 2>/dev/null`);
553
+ const out = await tryExec2(executor, `caddy adapt --adapter caddyfile --config ${p} 2>/dev/null`);
244
554
  if (out && out.trim().startsWith("{")) return out;
245
555
  }
246
556
  return null;
247
557
  }
248
- function flattenHandlers(handlers, out = []) {
558
+ function flattenHandlers(handlers, scoped = false, out = []) {
249
559
  for (const h of handlers ?? []) {
250
560
  if (h.handler === "subroute") {
251
- for (const r of h.routes ?? []) flattenHandlers(r.handle, out);
561
+ for (const r of h.routes ?? []) {
562
+ flattenHandlers(r.handle, scoped || (r.match?.length ?? 0) > 0, out);
563
+ }
252
564
  } else {
253
- out.push(h);
565
+ out.push({ h, scoped });
254
566
  }
255
567
  }
256
568
  return out;
257
569
  }
570
+ var UNMIGRATABLE_HANDLERS = {
571
+ php_fastcgi: "a PHP-FPM site (php_fastcgi)",
572
+ fastcgi: "a FastCGI backend",
573
+ static_response: "a static inline response (respond)",
574
+ templates: "Caddy templates",
575
+ encode: "",
576
+ headers: "",
577
+ authentication: "Caddy authentication",
578
+ rewrite: "",
579
+ acme_server: "an embedded ACME server"
580
+ };
258
581
  function targetFromRoute(route) {
259
582
  const flat = flattenHandlers(route.handle);
260
- const dial = flat.find((h) => h.handler === "reverse_proxy")?.upstreams?.find((u) => u.dial)?.dial;
261
- if (dial) return { kind: "proxy", url: /^https?:\/\//.test(dial) ? dial : `http://${dial}` };
262
- const root = flat.find((h) => h.handler === "vars" && typeof h.root === "string")?.root;
263
- if (root && flat.some((h) => h.handler === "file_server")) return { kind: "static", root };
264
- return null;
583
+ const proxies = flat.filter((f) => f.h.handler === "reverse_proxy");
584
+ const chosen = proxies.find((f) => !f.scoped) ?? proxies[0];
585
+ const proxy = chosen?.h;
586
+ if (proxy?.transport?.protocol === "fastcgi") {
587
+ return { note: "serves a PHP-FPM/FastCGI site (php_fastcgi) \u2014 not an HTTP upstream" };
588
+ }
589
+ const pathScopedOnly = Boolean(chosen?.scoped);
590
+ const hasScopedSibling = proxies.some((f) => f.scoped);
591
+ const dials = (proxy?.upstreams ?? []).map((u) => u.dial).filter((d) => Boolean(d));
592
+ if (dials.length > 0) {
593
+ const dial = dials[0];
594
+ if (/^unix\//i.test(dial)) {
595
+ return { note: `proxies to the unix socket "${dial}", which needs a manual upstream` };
596
+ }
597
+ return {
598
+ target: { kind: "proxy", url: /^https?:\/\//.test(dial) ? dial : `http://${dial}` },
599
+ extraUpstreams: dials.length - 1,
600
+ pathScopedOnly,
601
+ hasScopedSibling
602
+ };
603
+ }
604
+ const root = flat.find((f) => f.h.handler === "vars" && typeof f.h.root === "string")?.h.root;
605
+ if (root && flat.some((f) => f.h.handler === "file_server")) {
606
+ return { target: { kind: "static", root } };
607
+ }
608
+ for (const { h } of flat) {
609
+ const label = h.handler ? UNMIGRATABLE_HANDLERS[h.handler] : void 0;
610
+ if (label) return { note: `serves ${label}` };
611
+ }
612
+ return {};
265
613
  }
266
614
  function parseCaddyJson(raw) {
267
615
  let cfg;
@@ -273,21 +621,51 @@ function parseCaddyJson(raw) {
273
621
  const servers = cfg.apps?.http?.servers;
274
622
  if (!servers) return null;
275
623
  const sites = [];
624
+ const warnings = [];
276
625
  for (const srv of Object.values(servers)) {
277
626
  const ssl = (srv.listen ?? []).some((l) => l.endsWith(":443"));
278
627
  for (const route of srv.routes ?? []) {
279
628
  const hosts = (route.match ?? []).flatMap((m) => m.host ?? []).filter((h) => h && h !== "*");
280
629
  if (hosts.length === 0) continue;
281
- const target = targetFromRoute(route);
282
- if (!target) continue;
283
- sites.push({ serverNames: hosts, ssl, target, source: "caddy (adapt)" });
630
+ const outcome = targetFromRoute(route);
631
+ if (outcome.note) {
632
+ warnings.push(`caddy: ${hosts.join(", ")} ${outcome.note} \u2014 re-add it manually`);
633
+ continue;
634
+ }
635
+ if (!outcome.target) continue;
636
+ if (outcome.extraUpstreams) {
637
+ warnings.push(
638
+ `caddy: ${hosts.join(", ")} load-balances across ${outcome.extraUpstreams + 1} upstreams \u2014 only the first is migrated`
639
+ );
640
+ }
641
+ if (outcome.hasScopedSibling || (route.match ?? []).some((m) => m.path?.length)) {
642
+ warnings.push(
643
+ `caddy: ${hosts.join(", ")} routes some paths to other upstreams \u2014 only the catch-all is migrated; re-add path rules manually`
644
+ );
645
+ }
646
+ if (outcome.pathScopedOnly) {
647
+ warnings.push(
648
+ `caddy: ${hosts.join(", ")} has no catch-all upstream \u2014 used its path-scoped one; verify the target`
649
+ );
650
+ }
651
+ sites.push({ serverNames: hosts, ssl, target: outcome.target, source: "caddy (adapt)" });
284
652
  }
285
653
  }
286
- return { sites, warnings: [] };
654
+ const { kept, dropped } = collapseByHost(
655
+ sites,
656
+ (s) => s.serverNames,
657
+ (s) => s.ssl ? 1 : 0
658
+ );
659
+ for (const d of dropped) {
660
+ warnings.push(
661
+ `caddy: ${d.serverNames.join(", ")} had a second (non-TLS) block \u2014 kept the TLS one`
662
+ );
663
+ }
664
+ return { sites: kept, warnings };
287
665
  }
288
666
  async function loadCaddyfile(executor) {
289
667
  for (const p of CADDYFILE_PATHS) {
290
- const out = await tryExec(executor, `cat ${p} 2>/dev/null`);
668
+ const out = await tryExec2(executor, `cat ${p} 2>/dev/null`);
291
669
  if (out && out.trim()) return out;
292
670
  }
293
671
  return "";
@@ -324,10 +702,11 @@ function parseAddresses(header) {
324
702
  }).filter((a) => a.host && !a.host.startsWith(":") && a.host !== "*");
325
703
  }
326
704
  async function scanCaddy(executor) {
327
- const json = await loadCaddyJson(executor);
328
- if (json) {
705
+ for (const load of [loadCaddyAdminConfig, loadCaddyJson]) {
706
+ const json = await load(executor);
707
+ if (!json) continue;
329
708
  const parsed = parseCaddyJson(json);
330
- if (parsed && parsed.sites.length > 0) {
709
+ if (parsed && (parsed.sites.length > 0 || parsed.warnings.length > 0)) {
331
710
  return { proxy: "caddy", sites: parsed.sites, warnings: parsed.warnings };
332
711
  }
333
712
  }
@@ -357,19 +736,44 @@ async function scanCaddyText(executor) {
357
736
  warnings.push(`caddy: skipped a block with no usable address (${header.slice(0, 40)})`);
358
737
  continue;
359
738
  }
360
- const proxyMatch = body.match(/(?:^|\s)reverse_proxy\s+([^\n{]+)/);
739
+ const inlineProxy = body.match(/(?:^|\s)reverse_proxy\s+([^\n{]+)/);
740
+ const blockProxy = body.match(/(?:^|\s)reverse_proxy\s*\{([\s\S]*?)\}/);
741
+ const blockTo = blockProxy?.[1].match(/(?:^|\n)\s*to\s+([^\n]+)/);
361
742
  const rootMatch = body.match(/(?:^|\s)root\s+\*?\s*([^\n\s]+)/);
362
743
  const tlsMatch = body.match(/(?:^|\s)tls\s+(\S+)\s+(\S+)/);
744
+ const upstreams = (inlineProxy?.[1] ?? blockTo?.[1] ?? "").trim().split(/\s+/).filter(Boolean);
745
+ const host = addrs[0].host;
363
746
  let target;
364
- if (proxyMatch) {
365
- const upstream = proxyMatch[1].trim().split(/\s+/)[0];
747
+ if (upstreams.length > 0) {
748
+ const upstream = upstreams[0];
749
+ if (/^unix\//i.test(upstream)) {
750
+ warnings.push(
751
+ `caddy: ${host} proxies to the unix socket "${upstream}" \u2014 needs a manual upstream, skipped`
752
+ );
753
+ continue;
754
+ }
755
+ if (upstreams.length > 1) {
756
+ warnings.push(
757
+ `caddy: ${host} load-balances across ${upstreams.length} upstreams \u2014 only the first is migrated`
758
+ );
759
+ }
366
760
  target = { kind: "proxy", url: /^https?:\/\//.test(upstream) ? upstream : `http://${upstream}` };
761
+ } else if (/(?:^|\s)php_fastcgi\s/.test(body)) {
762
+ warnings.push(
763
+ `caddy: ${host} serves a PHP-FPM site (php_fastcgi) \u2014 not migratable as a static root; re-add it manually`
764
+ );
765
+ continue;
367
766
  } else if (rootMatch) {
368
767
  target = { kind: "static", root: rootMatch[1] };
369
768
  } else {
370
- warnings.push(`caddy: ${addrs[0].host} has no reverse_proxy or root \u2014 skipped`);
769
+ warnings.push(`caddy: ${host} has no reverse_proxy or root \u2014 skipped`);
371
770
  continue;
372
771
  }
772
+ if (/(?:^|\s)(handle_path|handle)\s+\//.test(body)) {
773
+ warnings.push(
774
+ `caddy: ${host} routes by path (handle/handle_path) \u2014 only one upstream is migrated; re-add path rules manually`
775
+ );
776
+ }
373
777
  const site = {
374
778
  serverNames: addrs.map((a) => a.host),
375
779
  ssl: addrs.some((a) => a.ssl),
@@ -381,12 +785,22 @@ async function scanCaddyText(executor) {
381
785
  }
382
786
  sites.push(site);
383
787
  }
384
- return { proxy: "caddy", sites, warnings };
788
+ const { kept, dropped } = collapseByHost(
789
+ sites,
790
+ (s) => s.serverNames,
791
+ (s) => s.ssl ? 1 : 0
792
+ );
793
+ for (const d of dropped) {
794
+ warnings.push(
795
+ `caddy: ${d.serverNames.join(", ")} had a second (non-TLS) block \u2014 kept the TLS one`
796
+ );
797
+ }
798
+ return { proxy: "caddy", sites: kept, warnings };
385
799
  }
386
800
 
387
801
  // ../../packages/adapters/src/system/proxy/import/apache.ts
388
802
  async function vhostFilesFromDump(executor) {
389
- const dump = await tryExec(
803
+ const dump = await tryExec2(
390
804
  executor,
391
805
  "apachectl -S 2>/dev/null || apache2ctl -S 2>/dev/null || httpd -S 2>/dev/null"
392
806
  );
@@ -401,42 +815,107 @@ async function vhostFilesFromDump(executor) {
401
815
  async function loadApacheConfig(executor) {
402
816
  const files = await vhostFilesFromDump(executor);
403
817
  if (files.length > 0) {
404
- const cat2 = await tryExec(executor, `cat ${files.map(sq3).join(" ")} 2>/dev/null`);
818
+ const cat2 = await tryExec2(executor, `cat ${files.map(sq3).join(" ")} 2>/dev/null`);
405
819
  if (cat2 && cat2.trim()) return cat2;
406
820
  }
407
- const cat = await tryExec(
821
+ const cat = await tryExec2(
408
822
  executor,
409
823
  "cat /etc/apache2/sites-enabled/*.conf /etc/apache2/apache2.conf /etc/httpd/conf.d/*.conf /etc/httpd/conf/httpd.conf 2>/dev/null"
410
824
  );
411
825
  return cat ?? "";
412
826
  }
827
+ function unquote(value) {
828
+ return value.replace(/^["']|["']$/g, "");
829
+ }
413
830
  function directive(body, name) {
414
831
  const m = body.match(new RegExp(`(?:^|\\n)\\s*${name}\\s+([^\\n]+)`, "i"));
415
- return m?.[1]?.trim();
832
+ const value = m?.[1]?.trim();
833
+ return value === void 0 ? void 0 : unquote(value);
416
834
  }
417
835
  function directiveAll(body, name) {
418
836
  return [...body.matchAll(new RegExp(`(?:^|\\n)\\s*${name}\\s+([^\\n]+)`, "gi"))].map(
419
837
  (m) => m[1].trim()
420
838
  );
421
839
  }
840
+ function isHttpsRedirectOnly(body) {
841
+ const hasProxy = /(?:^|\n)\s*ProxyPass\s+\S+\s+(?!!)\S/i.test(body);
842
+ if (hasProxy) return false;
843
+ const redirects = [
844
+ ...body.matchAll(/(?:^|\n)\s*Redirect(?:Permanent|Match)?\s+(?:\d{3}\s+|permanent\s+|temp\s+)?\S*\s*(\S+)/gi),
845
+ ...body.matchAll(/(?:^|\n)\s*RewriteRule\s+\S+\s+(\S+)\s*\[[^\]]*R=?3\d\d[^\]]*\]/gi)
846
+ ].map((m) => m[1]);
847
+ return redirects.length > 0 && redirects.some((t) => /^https:\/\//i.test(t));
848
+ }
849
+ function hostOnly(name) {
850
+ return unquote(name.trim()).replace(/^https?:\/\//i, "").replace(/:\d+$/, "");
851
+ }
852
+ function proxyRoutes(body) {
853
+ const routes = [];
854
+ const scopes = [];
855
+ let regexScoped = false;
856
+ for (const line of body.split("\n")) {
857
+ const open = line.match(/^\s*<Location(Match)?\s+([^>]*)>/i);
858
+ if (open) {
859
+ const regex = Boolean(open[1]);
860
+ const arg = unquote(open[2].trim());
861
+ scopes.push({ path: !regex && arg.startsWith("/") ? arg : null, regex });
862
+ continue;
863
+ }
864
+ if (/^\s*<\/Location(?:Match)?\s*>/i.test(line)) {
865
+ scopes.pop();
866
+ continue;
867
+ }
868
+ const m = line.match(/^\s*ProxyPass\s+(\S+)(?:\s+(\S+))?/i);
869
+ if (!m) continue;
870
+ const first = unquote(m[1]);
871
+ if (first.startsWith("/")) {
872
+ routes.push({ path: first, url: m[2] ? unquote(m[2]) : "" });
873
+ continue;
874
+ }
875
+ const scope = scopes[scopes.length - 1];
876
+ if (scope?.path) routes.push({ path: scope.path, url: first });
877
+ else if (scope?.regex) regexScoped = true;
878
+ }
879
+ return { routes, regexScoped };
880
+ }
422
881
  function parseVhost(body, portHint) {
423
882
  const serverName = directive(body, "ServerName")?.split(/\s+/)[0];
424
883
  const aliases = directiveAll(body, "ServerAlias").flatMap((line) => line.split(/\s+/));
425
- const names = [serverName, ...aliases].filter((n) => Boolean(n));
884
+ const names = [serverName, ...aliases].filter((n) => Boolean(n)).map(hostOnly).filter(Boolean);
426
885
  if (names.length === 0) {
427
886
  return { warning: "apache: skipped a VirtualHost with no ServerName" };
428
887
  }
429
- const routes = [...body.matchAll(/(?:^|\n)\s*ProxyPass\s+(\S+)\s+(\S+)/gi)].map((m) => ({ path: m[1], url: m[2] })).filter((p) => p.url && p.url !== "!");
430
- const docRoot = directive(body, "DocumentRoot")?.replace(/^["']|["']$/g, "");
888
+ if (isHttpsRedirectOnly(body)) return null;
889
+ const proxy = proxyRoutes(body);
890
+ const routes = proxy.routes.filter((p) => p.url && p.url !== "!");
891
+ const docRoot = directive(body, "DocumentRoot");
892
+ const fcgiMatch = /(?:^|\n)\s*ProxyPassMatch\s+[^\n]*(fcgi:|php-fpm|php\d*-fpm|\.sock)/i.test(body);
431
893
  const certPath = directive(body, "SSLCertificateFile");
432
894
  const keyPath = directive(body, "SSLCertificateKeyFile");
433
895
  const ssl = /:443/.test(portHint) || /SSLEngine\s+on/i.test(body) || Boolean(certPath);
434
896
  let target;
435
897
  if (routes.length > 0) {
436
898
  const primary = routes.find((r) => r.path === "/") ?? routes[0];
899
+ if (/^balancer:\/\//i.test(primary.url)) {
900
+ return {
901
+ warning: `apache: ${names[0]} proxies to an mod_proxy_balancer cluster (${primary.url}) \u2014 re-add its members manually`
902
+ };
903
+ }
437
904
  target = { kind: "proxy", url: primary.url };
905
+ } else if (fcgiMatch) {
906
+ return {
907
+ warning: `apache: ${names[0]} is a PHP-FPM site (ProxyPassMatch \u2192 FastCGI) \u2014 not migratable as a static root; re-add it manually`
908
+ };
438
909
  } else if (docRoot) {
439
910
  target = { kind: "static", root: docRoot };
911
+ } else if (/(?:^|\n)\s*ProxyPassMatch\s/i.test(body)) {
912
+ return {
913
+ warning: `apache: ${names[0]} uses ProxyPassMatch (regex proxying) \u2014 re-add it manually`
914
+ };
915
+ } else if (proxy.regexScoped) {
916
+ return {
917
+ warning: `apache: ${names[0]} proxies from inside a <LocationMatch> (regex proxying) \u2014 re-add it manually`
918
+ };
440
919
  } else {
441
920
  return { warning: `apache: ${names[0]} has neither ProxyPass nor DocumentRoot \u2014 skipped` };
442
921
  }
@@ -445,6 +924,11 @@ function parseVhost(body, portHint) {
445
924
  if (certPath && keyPath) site.tls = { certPath, keyPath };
446
925
  return site;
447
926
  }
927
+ function redirectVhostNames(body) {
928
+ const serverName = directive(body, "ServerName")?.split(/\s+/)[0];
929
+ const aliases = directiveAll(body, "ServerAlias").flatMap((l) => l.split(/\s+/));
930
+ return [serverName, ...aliases].filter((n) => Boolean(n)).map(hostOnly).filter(Boolean);
931
+ }
448
932
  function vhostBlocks(text) {
449
933
  const out = [];
450
934
  const re = /<VirtualHost([^>]*)>([\s\S]*?)<\/VirtualHost>/gi;
@@ -464,15 +948,44 @@ async function scanApache(executor) {
464
948
  const text = stripComments(raw);
465
949
  const blocks = vhostBlocks(text);
466
950
  if (blocks.length === 0) warnings.push("apache: no <VirtualHost> blocks found");
951
+ const redirectOnlyHosts = [];
467
952
  for (const { args, body } of blocks) {
468
953
  const parsed = parseVhost(body, args);
954
+ if (parsed === null) {
955
+ redirectOnlyHosts.push(redirectVhostNames(body));
956
+ continue;
957
+ }
469
958
  if ("warning" in parsed) warnings.push(parsed.warning);
470
959
  else sites.push(parsed);
471
960
  }
472
- return { proxy: "apache", sites, warnings };
961
+ const served = new Set(sites.flatMap((s) => s.serverNames));
962
+ for (const names of redirectOnlyHosts) {
963
+ const orphaned = names.filter((n) => !served.has(n));
964
+ if (orphaned.length > 0) {
965
+ warnings.push(
966
+ `apache: ${orphaned.join(", ")} only redirected to HTTPS and has no TLS VirtualHost here \u2014 nothing to serve it after migration`
967
+ );
968
+ }
969
+ }
970
+ const { kept, dropped } = collapseByHost(
971
+ sites,
972
+ (s) => s.serverNames,
973
+ (s) => (s.ssl ? 2 : 0) + (s.target.kind === "proxy" ? 1 : 0)
974
+ );
975
+ for (const d of dropped) {
976
+ warnings.push(
977
+ `apache: ${d.serverNames.join(", ")} had a second VirtualHost (${d.ssl ? "TLS" : "plain HTTP"}, ${d.target.kind}) \u2014 kept the TLS/proxy one`
978
+ );
979
+ }
980
+ return { proxy: "apache", sites: kept, warnings };
473
981
  }
474
982
 
475
983
  // ../../packages/adapters/src/system/proxy/import/traefik.ts
984
+ function lowerKeys(labels) {
985
+ const out = {};
986
+ for (const [k, v] of Object.entries(labels)) out[k.toLowerCase()] = v;
987
+ return out;
988
+ }
476
989
  function extractHosts(rule) {
477
990
  const hosts = [];
478
991
  for (const call of rule.matchAll(/\bHost\(([^)]*)\)/gi)) {
@@ -481,7 +994,7 @@ function extractHosts(rule) {
481
994
  if (h) hosts.push(h);
482
995
  }
483
996
  }
484
- return hosts;
997
+ return [...new Set(hosts)];
485
998
  }
486
999
  function extraMatchers(rule) {
487
1000
  const found = /* @__PURE__ */ new Set();
@@ -492,23 +1005,59 @@ function extraMatchers(rule) {
492
1005
  }
493
1006
  return [...found];
494
1007
  }
1008
+ function collectDefinitions(containers) {
1009
+ const services = /* @__PURE__ */ new Map();
1010
+ const redirectMiddlewares = /* @__PURE__ */ new Set();
1011
+ const streamRouters = [];
1012
+ let fileProvider = false;
1013
+ let exposedByDefault = true;
1014
+ for (const c of containers) {
1015
+ const labels = lowerKeys(c.labels);
1016
+ for (const [k, v] of Object.entries(labels)) {
1017
+ let m = k.match(/^traefik\.http\.services\.([^.]+)\.loadbalancer\.server\.(port|scheme)$/);
1018
+ if (m) {
1019
+ const def = services.get(m[1]) ?? {};
1020
+ if (m[2] === "port") def.port = v;
1021
+ else def.scheme = v;
1022
+ services.set(m[1], def);
1023
+ continue;
1024
+ }
1025
+ m = k.match(/^traefik\.http\.middlewares\.([^.]+)\.redirectscheme\./);
1026
+ if (m) {
1027
+ redirectMiddlewares.add(m[1]);
1028
+ continue;
1029
+ }
1030
+ m = k.match(/^traefik\.(tcp|udp)\.routers\.([^.]+)\.rule$/);
1031
+ if (m) streamRouters.push(`${m[1]} router "${m[2]}"`);
1032
+ }
1033
+ const cmd = c.cmd ?? "";
1034
+ if (/--?providers\.file/i.test(cmd)) fileProvider = true;
1035
+ if (/--?providers\.docker\.exposedbydefault[= ]?false/i.test(cmd)) exposedByDefault = false;
1036
+ }
1037
+ return { services, redirectMiddlewares, streamRouters, fileProvider, exposedByDefault };
1038
+ }
1039
+ function resolveIp(c, labels) {
1040
+ const wanted = labels["traefik.docker.network"];
1041
+ if (wanted && c.networks?.[wanted]) return c.networks[wanted];
1042
+ if (c.ip) return c.ip;
1043
+ const first = Object.values(c.networks ?? {}).find(Boolean);
1044
+ return first || void 0;
1045
+ }
495
1046
  function parseTraefikLabels(containers) {
496
- const sites = [];
497
1047
  const warnings = [];
1048
+ const defs = collectDefinitions(containers);
1049
+ const candidates = [];
498
1050
  for (const c of containers) {
499
- if (c.labels["traefik.enable"] === "false") continue;
1051
+ const labels = lowerKeys(c.labels);
1052
+ if (labels["traefik.enable"] === "false") continue;
1053
+ if (!defs.exposedByDefault && labels["traefik.enable"] !== "true") continue;
500
1054
  const routers = /* @__PURE__ */ new Map();
501
- const servicePorts = /* @__PURE__ */ new Map();
502
- for (const [key, val] of Object.entries(c.labels)) {
1055
+ for (const [key, val] of Object.entries(labels)) {
503
1056
  const r = key.match(/^traefik\.http\.routers\.([^.]+)\.(.+)$/);
504
- if (r) {
505
- const bag = routers.get(r[1]) ?? {};
506
- bag[r[2]] = val;
507
- routers.set(r[1], bag);
508
- continue;
509
- }
510
- const s = key.match(/^traefik\.http\.services\.([^.]+)\.loadbalancer\.server\.port$/);
511
- if (s) servicePorts.set(s[1], val);
1057
+ if (!r) continue;
1058
+ const bag = routers.get(r[1]) ?? {};
1059
+ bag[r[2]] = val;
1060
+ routers.set(r[1], bag);
512
1061
  }
513
1062
  for (const [rname, props] of routers) {
514
1063
  const rule = props.rule;
@@ -520,47 +1069,92 @@ function parseTraefikLabels(containers) {
520
1069
  );
521
1070
  continue;
522
1071
  }
523
- if (!c.ip) {
1072
+ const ip = resolveIp(c, labels);
1073
+ if (!ip) {
524
1074
  warnings.push(
525
1075
  `traefik: ${hosts.join(", ")} \u2014 couldn't resolve container ${c.name}'s IP; re-add the upstream manually`
526
1076
  );
527
- continue;
528
- }
529
- const svc = props.service;
530
- const rawPort = (svc && servicePorts.get(svc)) ?? (servicePorts.size === 1 ? [...servicePorts.values()][0] : void 0) ?? "80";
531
- const port = /^\d{1,5}$/.test(String(rawPort)) ? String(rawPort) : "80";
532
- const url = `http://${c.ip}:${port}`;
533
- const extras = extraMatchers(rule);
534
- if (extras.length > 0) {
535
- warnings.push(
536
- `traefik: ${hosts.join(", ")} also matches ${extras.join(", ")} \u2014 only the Host() part is migrated; re-add path/header rules manually`
537
- );
538
- }
539
- if (props.middlewares) {
540
- warnings.push(`traefik: ${hosts.join(", ")} uses middleware(s) "${props.middlewares}" \u2014 not migrated`);
1077
+ continue;
541
1078
  }
542
- const ssl = Object.keys(props).some((p) => p === "tls" || p.startsWith("tls."));
543
- sites.push({
544
- serverNames: hosts,
545
- ssl,
546
- target: { kind: "proxy", url },
547
- source: `traefik container ${c.name}`
1079
+ const svc = props.service?.replace(/@.*$/, "");
1080
+ const def = (svc ? defs.services.get(svc) : void 0) ?? (defs.services.size === 1 ? [...defs.services.values()][0] : void 0);
1081
+ const onlyExposed = c.exposedPorts?.length === 1 ? c.exposedPorts[0].split("/")[0] : void 0;
1082
+ const rawPort = def?.port ?? onlyExposed;
1083
+ const port = /^\d{1,5}$/.test(String(rawPort)) ? String(rawPort) : "80";
1084
+ const scheme = def?.scheme?.toLowerCase() === "https" ? "https" : "http";
1085
+ const middlewares = (props.middlewares ?? "").split(",").map((m) => m.trim().replace(/@.*$/, "")).filter(Boolean);
1086
+ candidates.push({
1087
+ hosts,
1088
+ ssl: Object.keys(props).some((p) => p === "tls" || p.startsWith("tls.")),
1089
+ url: `${scheme}://${ip}:${port}`,
1090
+ container: c.name,
1091
+ router: rname,
1092
+ rule,
1093
+ middlewares,
1094
+ redirectOnly: middlewares.length > 0 && middlewares.every((m) => defs.redirectMiddlewares.has(m)),
1095
+ portGuessed: rawPort === void 0
548
1096
  });
549
1097
  }
550
1098
  }
551
- return { proxy: "traefik", sites, warnings };
1099
+ const { kept: sites, dropped } = collapseByHost(
1100
+ candidates,
1101
+ (c) => c.hosts,
1102
+ (c) => c.redirectOnly ? 0 : c.ssl ? 2 : 1
1103
+ );
1104
+ for (const cand of sites) {
1105
+ const extras = extraMatchers(cand.rule);
1106
+ if (extras.length > 0) {
1107
+ warnings.push(
1108
+ `traefik: ${cand.hosts.join(", ")} also matches ${extras.join(", ")} \u2014 only the Host() part is migrated; re-add path/header rules manually`
1109
+ );
1110
+ }
1111
+ const carried = cand.middlewares.filter((m) => !defs.redirectMiddlewares.has(m));
1112
+ if (carried.length > 0) {
1113
+ warnings.push(
1114
+ `traefik: ${cand.hosts.join(", ")} uses middleware(s) "${carried.join(", ")}" \u2014 not migrated`
1115
+ );
1116
+ }
1117
+ if (cand.portGuessed) {
1118
+ warnings.push(
1119
+ `traefik: ${cand.hosts.join(", ")} \u2014 no service port label and no single exposed port on ${cand.container}; assumed :80, check the upstream`
1120
+ );
1121
+ }
1122
+ }
1123
+ for (const d of dropped) {
1124
+ warnings.push(
1125
+ `traefik: ${d.hosts.join(", ")} router "${d.router}" is an http\u2192https redirect \u2014 Openship's edge does that itself (nothing to migrate)`
1126
+ );
1127
+ }
1128
+ for (const s of defs.streamRouters) {
1129
+ warnings.push(`traefik: ${s} can't migrate \u2014 Openship's edge routes HTTP(S), not raw TCP/UDP`);
1130
+ }
1131
+ if (defs.fileProvider) {
1132
+ warnings.push(
1133
+ "traefik: a file provider is configured \u2014 routes defined in its YAML/TOML files are NOT read here, only container labels. Re-add those domains manually."
1134
+ );
1135
+ }
1136
+ return {
1137
+ proxy: "traefik",
1138
+ sites: sites.map((cand) => ({
1139
+ serverNames: cand.hosts,
1140
+ ssl: cand.ssl,
1141
+ target: { kind: "proxy", url: cand.url },
1142
+ source: `traefik container ${cand.container}`
1143
+ })),
1144
+ warnings
1145
+ };
552
1146
  }
553
1147
  async function scanTraefik(executor) {
554
- const out = await tryExec(
1148
+ const out = await tryExec2(
555
1149
  executor,
556
- "docker ps -q 2>/dev/null | xargs -r docker inspect --format '{{.Name}} {{json .Config.Labels}} {{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' 2>/dev/null"
1150
+ `docker ps -q 2>/dev/null | xargs -r docker inspect --format '{{.Name}} {{json .Config.Labels}} {{range $n, $v := .NetworkSettings.Networks}}{{$n}}={{$v.IPAddress}} {{end}} {{range $p, $v := .Config.ExposedPorts}}{{$p}} {{end}} {{if .Config.Cmd}}{{join .Config.Cmd " "}}{{end}}' 2>/dev/null`
557
1151
  );
558
1152
  if (!out) {
559
1153
  return { proxy: "traefik", sites: [], warnings: ["traefik: couldn't read container labels via docker inspect"] };
560
1154
  }
561
1155
  const containers = [];
562
1156
  for (const line of out.split("\n")) {
563
- const [rawName, rawLabels, rawIps] = line.split(" ");
1157
+ const [rawName, rawLabels, rawNets, rawPorts, rawCmd] = line.split(" ");
564
1158
  if (!rawName || !rawLabels) continue;
565
1159
  let labels;
566
1160
  try {
@@ -568,9 +1162,30 @@ async function scanTraefik(executor) {
568
1162
  } catch {
569
1163
  continue;
570
1164
  }
571
- if (!Object.keys(labels).some((k) => k.startsWith("traefik."))) continue;
572
- const ip = (rawIps ?? "").trim().split(/\s+/)[0] || void 0;
573
- containers.push({ name: rawName.replace(/^\//, ""), labels, ip });
1165
+ const networks = {};
1166
+ let bareIp;
1167
+ for (const pair of (rawNets ?? "").trim().split(/\s+/)) {
1168
+ if (!pair) continue;
1169
+ const eq = pair.indexOf("=");
1170
+ if (eq > 0) {
1171
+ const ip = pair.slice(eq + 1);
1172
+ if (ip) networks[pair.slice(0, eq)] = ip;
1173
+ } else if (!bareIp) {
1174
+ bareIp = pair;
1175
+ }
1176
+ }
1177
+ const exposedPorts = (rawPorts ?? "").trim().split(/\s+/).filter(Boolean);
1178
+ const hasTraefikLabels = Object.keys(labels).some((k) => k.toLowerCase().startsWith("traefik."));
1179
+ const looksLikeTraefik = /traefik/i.test(rawCmd ?? "") || /--?providers\./i.test(rawCmd ?? "");
1180
+ if (!hasTraefikLabels && !looksLikeTraefik) continue;
1181
+ containers.push({
1182
+ name: rawName.replace(/^\//, ""),
1183
+ labels,
1184
+ ip: Object.values(networks)[0] ?? bareIp,
1185
+ networks,
1186
+ exposedPorts,
1187
+ cmd: rawCmd ?? void 0
1188
+ });
574
1189
  }
575
1190
  return parseTraefikLabels(containers);
576
1191
  }
@@ -612,7 +1227,7 @@ async function scanImportableSites(executor, proxy) {
612
1227
  case "traefik":
613
1228
  return scanTraefik(executor);
614
1229
  case "openresty":
615
- return scanOpenshipEdge(executor);
1230
+ return scanForeignOpenResty(executor);
616
1231
  default:
617
1232
  return {
618
1233
  proxy,
@@ -624,6 +1239,27 @@ async function scanImportableSites(executor, proxy) {
624
1239
  function canImportProxy(proxy) {
625
1240
  return proxy === "nginx" || proxy === "caddy" || proxy === "apache" || proxy === "traefik" || proxy === "openresty";
626
1241
  }
1242
+ function unreachableStaticRoots(sites, opts) {
1243
+ if (!opts.containerEdge) return [];
1244
+ const visible = EDGE_CONTAINER_MOUNTS.map((m) => m.host.replace(/\/+$/, ""));
1245
+ const out = [];
1246
+ for (const site of sites) {
1247
+ if (site.target.kind !== "static") continue;
1248
+ const root = site.target.root.replace(/\/+$/, "");
1249
+ const reachable = visible.some((v) => root === v || root.startsWith(`${v}/`));
1250
+ if (!reachable) out.push({ host: site.serverNames[0] ?? root, root: site.target.root });
1251
+ }
1252
+ return out;
1253
+ }
1254
+ var EDGE_STATIC_MOUNT = EDGE_CONTAINER_MOUNTS.find((m) => m.host === "/opt/openship/static")?.host ?? "/opt/openship/static";
1255
+ async function copyStaticRootIntoEdge(executor, opts) {
1256
+ const slug = opts.host.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "site";
1257
+ const src = opts.root.replace(/\/+$/, "");
1258
+ const dest = `${EDGE_STATIC_MOUNT}/_adopted/${slug}`;
1259
+ await executor.exec(`mkdir -p ${sq2(dest)}`);
1260
+ await executor.exec(`cp -a ${sq2(`${src}/.`)} ${sq2(`${dest}/`)}`);
1261
+ return dest;
1262
+ }
627
1263
 
628
1264
  // ../../packages/adapters/src/system/proxy/import/caddy-certs.ts
629
1265
  var CADDY_DATA_ROOTS = [
@@ -641,10 +1277,10 @@ function candidateNames(host) {
641
1277
  async function findPairIn(exec, root, host, container) {
642
1278
  for (const name of candidateNames(host)) {
643
1279
  const glob = `${root}/certificates/*/${name}/${name}.crt`;
644
- let certPath = firstCrt(await tryExec(exec, `ls -1 ${glob} 2>/dev/null`));
1280
+ let certPath = firstCrt(await tryExec2(exec, `ls -1 ${glob} 2>/dev/null`));
645
1281
  if (!certPath && container) {
646
1282
  certPath = firstCrt(
647
- await tryExec(exec, containerCommand(container, `ls -1 ${glob} 2>/dev/null`))
1283
+ await tryExec2(exec, containerCommand(container, `ls -1 ${glob} 2>/dev/null`))
648
1284
  );
649
1285
  }
650
1286
  if (certPath) return { certPath, keyPath: certPath.replace(/\.crt$/, ".key") };
@@ -690,7 +1326,7 @@ async function candidateStoragePaths(exec, container) {
690
1326
  };
691
1327
  if (container) {
692
1328
  collect(
693
- await tryExec(
1329
+ await tryExec2(
694
1330
  exec,
695
1331
  `docker inspect ${sq2(container)} --format '{{json .Config.Cmd}} {{json .Args}} {{json .Config.Env}}' 2>/dev/null`
696
1332
  )
@@ -794,11 +1430,13 @@ function buildProxyRouteIndex(sites) {
794
1430
  existing.ssl.keyPath = site.tls.keyPath;
795
1431
  }
796
1432
  }
1433
+ if (site.proxy && (site.ssl || !existing.proxy)) existing.proxy = site.proxy;
797
1434
  } else {
798
1435
  list.push({
799
1436
  port,
800
1437
  path: up.path,
801
1438
  domains: [...site.serverNames],
1439
+ ...site.proxy ? { proxy: site.proxy } : {},
802
1440
  ssl: site.ssl ? { enabled: true, certPath: site.tls?.certPath, keyPath: site.tls?.keyPath } : { enabled: false },
803
1441
  source: site.source
804
1442
  });
@@ -855,7 +1493,7 @@ async function edgeProxy(exec, opts = {}) {
855
1493
  return makeApi(exec, kind, ours, container);
856
1494
  }
857
1495
  function edgeProxyFor(exec, kind, opts = {}) {
858
- return makeApi(exec, kind, opts.ours ?? kind === "openresty", opts.container ?? null);
1496
+ return makeApi(exec, kind, opts.ours ?? false, opts.container ?? null);
859
1497
  }
860
1498
  async function collectProxyCerts(api, sites) {
861
1499
  const certPems = {};
@@ -919,147 +1557,6 @@ function makeApi(exec, kind, ours, container) {
919
1557
  };
920
1558
  }
921
1559
 
922
- // ../../packages/adapters/src/system/proxy/takeover-journal.ts
923
- var JOURNAL_DIR = "/var/lib/openship";
924
- var JOURNAL_PATH = `${JOURNAL_DIR}/edge-takeover.json`;
925
- function log(message, level = "info") {
926
- return { timestamp: (/* @__PURE__ */ new Date()).toISOString(), message, level };
927
- }
928
- async function tryExec2(executor, cmd) {
929
- try {
930
- return await executor.exec(cmd);
931
- } catch {
932
- return null;
933
- }
934
- }
935
- async function buildJournal(executor, status) {
936
- const units = /* @__PURE__ */ new Map();
937
- const containers = /* @__PURE__ */ new Map();
938
- const processes = /* @__PURE__ */ new Map();
939
- for (const o of status.occupants) {
940
- if (o.containerName && !containers.has(o.containerName)) {
941
- const r = await tryExec2(
942
- executor,
943
- `docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' ${sq2(o.containerName)} 2>/dev/null`
944
- );
945
- containers.set(o.containerName, { name: o.containerName, restart: r?.trim() || "no" });
946
- } else if (o.systemdUnit && !units.has(o.systemdUnit)) {
947
- const en = await tryExec2(executor, `systemctl is-enabled ${sq2(o.systemdUnit)} 2>/dev/null`);
948
- units.set(o.systemdUnit, { unit: o.systemdUnit, wasEnabled: en?.trim() === "enabled" });
949
- } else if (!o.containerName && !o.systemdUnit && o.pid && !processes.has(o.pid)) {
950
- processes.set(o.pid, { pid: o.pid, command: o.rawCommand });
951
- }
952
- }
953
- return {
954
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
955
- units: [...units.values()],
956
- containers: [...containers.values()],
957
- processes: [...processes.values()]
958
- };
959
- }
960
- async function writeJournal(executor, journal) {
961
- try {
962
- await executor.mkdir(JOURNAL_DIR);
963
- await executor.writeFile(JOURNAL_PATH, JSON.stringify(journal, null, 2));
964
- } catch {
965
- }
966
- }
967
- async function clearJournal(executor) {
968
- await tryExec2(executor, `rm -f ${JOURNAL_PATH}`);
969
- }
970
- async function readJournal(executor) {
971
- const raw = await tryExec2(executor, `cat ${JOURNAL_PATH} 2>/dev/null`);
972
- if (!raw?.trim()) return null;
973
- try {
974
- return JSON.parse(raw);
975
- } catch {
976
- await clearJournal(executor);
977
- return null;
978
- }
979
- }
980
- async function rollback(executor, journal, onLog) {
981
- onLog(log("Rolling back \u2014 restoring the previous proxy...", "warn"));
982
- await tryExec2(executor, `docker update --restart=no ${sq2(EDGE_CONTAINER_NAME)} 2>/dev/null || true`);
983
- await tryExec2(executor, `docker stop ${sq2(EDGE_CONTAINER_NAME)} 2>/dev/null || true`);
984
- await tryExec2(
985
- executor,
986
- "systemctl disable --now openresty 2>/dev/null || systemctl stop openresty 2>/dev/null || true; systemctl reset-failed openresty 2>/dev/null || true"
987
- );
988
- for (const u of journal.units) {
989
- await tryExec2(
990
- executor,
991
- u.wasEnabled ? `systemctl enable --now ${sq2(u.unit)} 2>/dev/null || true` : `systemctl start ${sq2(u.unit)} 2>/dev/null || true`
992
- );
993
- }
994
- for (const c of journal.containers) {
995
- await tryExec2(executor, `docker update --restart=${sq2(c.restart)} ${sq2(c.name)} 2>/dev/null || true`);
996
- await tryExec2(executor, `docker start ${sq2(c.name)} 2>/dev/null || true`);
997
- }
998
- for (const p of journal.processes ?? []) {
999
- if (p.command) {
1000
- await tryExec2(
1001
- executor,
1002
- `setsid -f sh -c ${sq2(p.command)} 2>/dev/null || (nohup sh -c ${sq2(p.command)} >/dev/null 2>&1 &) || true`
1003
- );
1004
- } else {
1005
- onLog(log(`Could not restore process ${p.pid} \u2014 no command captured.`, "warn"));
1006
- }
1007
- }
1008
- const restored = await portIsServed(executor, 80);
1009
- if (!restored) {
1010
- onLog(
1011
- log(
1012
- "Nothing is listening on :80 after the restore \u2014 the box is NOT serving. Start your proxy by hand (e.g. `systemctl start nginx`, or `docker start <name>`).",
1013
- "error"
1014
- )
1015
- );
1016
- }
1017
- return restored;
1018
- }
1019
- async function portIsServed(executor, port) {
1020
- const hex = port.toString(16).toUpperCase().padStart(4, "0");
1021
- const out = await tryExec2(
1022
- executor,
1023
- `(command -v ss >/dev/null 2>&1 && ss -ltn 2>/dev/null | grep -qE ':${port}[[:space:]]' && echo yes) || (grep -qiE '^[[:space:]]*[0-9]+:[[:space:]]*[0-9A-F]{8}:${hex}[[:space:]]+[0-9A-F]{8}:0000[[:space:]]+0A' /proc/net/tcp 2>/dev/null && echo yes) || true`
1024
- );
1025
- return (out ?? "").includes("yes");
1026
- }
1027
- async function beginEdgeTakeover(executor, status, onLog) {
1028
- await writeJournal(executor, await buildJournal(executor, status));
1029
- await freeEdgeTargets(executor, stopTargetsForStatus(status), (m, l) => onLog(log(m, l)));
1030
- }
1031
- async function rollbackEdgeTakeover(executor, onLog) {
1032
- const journal = await readJournal(executor);
1033
- if (!journal || journal.completed) return false;
1034
- const restored = await rollback(executor, journal, onLog);
1035
- await clearJournal(executor);
1036
- return restored;
1037
- }
1038
- async function completeEdgeTakeover(executor) {
1039
- const journal = await readJournal(executor);
1040
- if (journal) {
1041
- journal.completed = true;
1042
- await writeJournal(executor, journal);
1043
- }
1044
- await clearJournal(executor);
1045
- }
1046
- async function recoverInterruptedTakeover(executor, onLog, isEdgeHealthy) {
1047
- const journal = await readJournal(executor);
1048
- if (!journal) return;
1049
- if (journal.completed) {
1050
- await clearJournal(executor);
1051
- return;
1052
- }
1053
- if (isEdgeHealthy && await isEdgeHealthy()) {
1054
- onLog(log("Found an unfinished edge takeover, but our edge is serving \u2014 keeping it.", "warn"));
1055
- await clearJournal(executor);
1056
- return;
1057
- }
1058
- onLog(log("Found an interrupted edge takeover \u2014 restoring the previous proxy.", "warn"));
1059
- await rollback(executor, journal, onLog);
1060
- await clearJournal(executor);
1061
- }
1062
-
1063
1560
  // ../../packages/adapters/src/system/catalog.ts
1064
1561
  function dockerInstallPlan(profile) {
1065
1562
  if (profile.os !== "linux") {
@@ -1117,120 +1614,15 @@ function rsyncInstallPlan(profile) {
1117
1614
  verifyCommand: "rsync --version | head -n 1"
1118
1615
  };
1119
1616
  }
1120
- var OPENRESTY_APT_SOURCES = [
1121
- // ARM64: openresty.org publishes aarch64 debs under a SEPARATE path
1122
- // (openresty.org/package/arm64/<repo>); the default path is amd64-only, so on
1123
- // aarch64 `apt-get install openresty` finds no candidate and `set -e` aborts
1124
- // the whole setup (issue #109). Switch the base URL by arch and tag the deb
1125
- // line with `arch=` so apt fetches the right index. `dpkg --print-architecture`
1126
- // returns exactly amd64/arm64 (matches OpenResty's path + arch qualifier).
1127
- 'OR_ARCH="$(dpkg --print-architecture 2>/dev/null || echo amd64)"',
1128
- 'if [ "$OR_ARCH" = arm64 ]; then OR_BASE="http://openresty.org/package/arm64/$REPO"; else OR_BASE="http://openresty.org/package/$REPO"; fi',
1129
- // `|| REPO_CODENAME=""` so a failed substitution (no lsb_release AND no
1130
- // /etc/os-release, e.g. a stripped container) doesn't trip `set -e` before
1131
- // the empty-var fallback below can pick a default.
1132
- 'REPO_CODENAME="$(lsb_release -sc 2>/dev/null || { . /etc/os-release 2>/dev/null && echo "$VERSION_CODENAME"; })" || REPO_CODENAME=""',
1133
- 'if [ "$REPO" = debian ]; then OR_FALLBACKS="bookworm bullseye"; else OR_FALLBACKS="noble jammy focal"; fi',
1134
- 'OR_CODENAME=""',
1135
- "for c in $REPO_CODENAME $OR_FALLBACKS; do",
1136
- ' if wget -q --spider --tries=2 --timeout=15 "$OR_BASE/dists/$c/Release"; then OR_CODENAME="$c"; break; fi',
1137
- "done",
1138
- 'if [ -z "$OR_CODENAME" ]; then case "$REPO" in debian) OR_CODENAME=bookworm ;; *) OR_CODENAME=noble ;; esac; fi',
1139
- '[ "$OR_CODENAME" = "$REPO_CODENAME" ] || echo "[openresty] apt repo has no codename $REPO_CODENAME; using nearest supported LTS $OR_CODENAME" >&2',
1140
- 'echo "deb [arch=$OR_ARCH signed-by=/usr/share/keyrings/openresty.gpg] $OR_BASE $OR_CODENAME main" > /etc/apt/sources.list.d/openresty.list'
1141
- ];
1142
- function openrestyInstallPlan(profile) {
1143
- if (profile.os !== "linux") {
1144
- return {
1145
- supported: false,
1146
- unsupportedReason: "OpenResty installation is only supported on Linux servers"
1147
- };
1148
- }
1149
- let installCommand;
1150
- if (profile.packageManager === "apt") {
1151
- const distro = profile.distro === "debian" ? "debian" : "ubuntu";
1152
- installCommand = [
1153
- "set -e",
1154
- // Heal a stale/bad openresty.list left by a prior run (e.g. the #86
1155
- // 'resolute' pin) BEFORE the first apt-get update reads it — otherwise
1156
- // set -e aborts here and the repo never gets rewritten.
1157
- "rm -f /etc/apt/sources.list.d/openresty.list",
1158
- "apt-get update -qq && apt-get install -y -qq wget gnupg2 lsb-release",
1159
- "wget -qO /tmp/openresty-pubkey.gpg https://openresty.org/package/pubkey.gpg",
1160
- "gpg --yes --dearmor -o /usr/share/keyrings/openresty.gpg /tmp/openresty-pubkey.gpg",
1161
- `REPO=${distro}`,
1162
- ...OPENRESTY_APT_SOURCES,
1163
- "apt-get update -qq && apt-get install -y -qq openresty"
1164
- ].join("\n");
1165
- } else if (profile.packageManager === "dnf") {
1166
- const distro = profile.distro === "fedora" ? "fedora" : "centos";
1167
- installCommand = `wget -qO /etc/yum.repos.d/openresty.repo https://openresty.org/package/${distro}/openresty.repo && dnf install -y openresty`;
1168
- } else if (profile.packageManager === "yum") {
1169
- installCommand = "wget -qO /etc/yum.repos.d/openresty.repo https://openresty.org/package/centos/openresty.repo && yum install -y openresty";
1170
- } else if (profile.packageManager === "apk") {
1171
- installCommand = [
1172
- "apk add --no-cache wget",
1173
- "wget -qO /etc/apk/keys/admin@openresty.com-5ea678a6.rsa.pub https://openresty.org/package/alpine/admin@openresty.com-5ea678a6.rsa.pub",
1174
- `. /etc/os-release && echo "https://openresty.org/package/alpine/v$( echo $VERSION_ID | cut -d. -f1,2 )/main" >> /etc/apk/repositories`,
1175
- "apk update && apk add openresty"
1176
- ].join(" && ");
1177
- } else {
1178
- installCommand = [
1179
- "set -e",
1180
- "if command -v apt-get >/dev/null 2>&1; then",
1181
- " rm -f /etc/apt/sources.list.d/openresty.list",
1182
- " apt-get update -qq && apt-get install -y -qq wget gnupg2 lsb-release",
1183
- " wget -qO /tmp/openresty-pubkey.gpg https://openresty.org/package/pubkey.gpg",
1184
- " gpg --yes --dearmor -o /usr/share/keyrings/openresty.gpg /tmp/openresty-pubkey.gpg",
1185
- ' DISTRO=$(. /etc/os-release 2>/dev/null && echo "$ID" || echo "ubuntu")',
1186
- ' case "$DISTRO" in debian) REPO=debian ;; *) REPO=ubuntu ;; esac',
1187
- ...OPENRESTY_APT_SOURCES.map((l) => " " + l),
1188
- " apt-get update -qq && apt-get install -y -qq openresty",
1189
- "elif command -v dnf >/dev/null 2>&1; then",
1190
- " wget -qO /etc/yum.repos.d/openresty.repo https://openresty.org/package/centos/openresty.repo && dnf install -y openresty",
1191
- "elif command -v yum >/dev/null 2>&1; then",
1192
- " wget -qO /etc/yum.repos.d/openresty.repo https://openresty.org/package/centos/openresty.repo && yum install -y openresty",
1193
- "elif command -v apk >/dev/null 2>&1; then",
1194
- " apk add --no-cache wget \\",
1195
- " && wget -qO /etc/apk/keys/admin@openresty.com-5ea678a6.rsa.pub https://openresty.org/package/alpine/admin@openresty.com-5ea678a6.rsa.pub \\",
1196
- ' && . /etc/os-release && echo "https://openresty.org/package/alpine/v$(echo $VERSION_ID | cut -d. -f1,2)/main" >> /etc/apk/repositories \\',
1197
- " && apk update && apk add openresty",
1198
- "else",
1199
- ' echo "No supported package manager found (tried apt-get, dnf, yum, apk)" >&2 && exit 1',
1200
- "fi"
1201
- ].join("\n");
1202
- }
1203
- return {
1204
- supported: true,
1205
- installCommand,
1206
- startCommand: profile.serviceManager === "systemd" ? "systemctl enable openresty && systemctl start openresty" : void 0,
1207
- verifyCommand: "openresty -v 2>&1"
1208
- };
1209
- }
1210
- function certbotInstallPlan(profile) {
1211
- const commands = {
1212
- apt: "apt-get update -qq && apt-get install -y -qq certbot",
1213
- dnf: "dnf install -y certbot",
1214
- yum: "yum install -y certbot",
1215
- apk: "apk add --no-cache certbot"
1216
- };
1217
- const installCommand = commands[profile.packageManager];
1218
- if (!installCommand) {
1219
- return {
1220
- supported: false,
1221
- unsupportedReason: "Certbot installation is only supported on Linux with apt, dnf, or yum"
1222
- };
1223
- }
1224
- return {
1225
- supported: true,
1226
- installCommand,
1227
- verifyCommand: "certbot --version 2>/dev/null"
1228
- };
1229
- }
1230
1617
  var systemCatalog = {
1231
1618
  checks: {
1232
1619
  docker: {
1233
1620
  versionCommand: "docker --version",
1621
+ // A POSITIVE signal, not a bare exit code: only a daemon that answered can
1622
+ // name its version. `docker info >/dev/null && echo ok` prints ok on ANY
1623
+ // exit-0, so a daemon answering nothing would read as healthy — trading #408's
1624
+ // false negative for a false positive. Leave stderr alone too; `checkDocker`
1625
+ // puts the failure text in the component message.
1234
1626
  daemonCommand: "docker info --format '{{.ServerVersion}}'",
1235
1627
  parseVersion: (output) => output.match(/Docker version ([^\s,]+)/)?.[1] ?? output,
1236
1628
  missingMessage: "Docker is not installed",
@@ -1261,12 +1653,15 @@ var systemCatalog = {
1261
1653
  missingMessage: "rsync is not installed"
1262
1654
  }
1263
1655
  },
1656
+ // No `openresty` / `certbot` plans: nothing apt-installs an edge any more. Both
1657
+ // shipped inside the `openship-edge` image, and their host-package plans (with the
1658
+ // openresty.org apt-repo codename probing that came with them) went with the
1659
+ // installers. `checks.openresty` stays — it parses the version out of the
1660
+ // CONTAINER. See installer.ts `installContainerEdge`.
1264
1661
  installs: {
1265
1662
  docker: dockerInstallPlan,
1266
1663
  git: gitInstallPlan,
1267
- rsync: rsyncInstallPlan,
1268
- openresty: openrestyInstallPlan,
1269
- certbot: certbotInstallPlan
1664
+ rsync: rsyncInstallPlan
1270
1665
  }
1271
1666
  };
1272
1667
 
@@ -1290,7 +1685,7 @@ function elevatedExecutor(inner) {
1290
1685
  };
1291
1686
  const overrides = {
1292
1687
  exec: (command, opts) => inner.exec(elevateCommand(command), opts),
1293
- streamExec: (command, onLog) => inner.streamExec(elevateCommand(command), onLog),
1688
+ streamExec: (command, onLog, opts) => inner.streamExec(elevateCommand(command), onLog, opts),
1294
1689
  writeFile: writeFileElevated,
1295
1690
  // Elevated for the same reason as writeFile: the vhost/conf dirs are root-owned,
1296
1691
  // and the Proxy would otherwise forward this to the inner executor unelevated —
@@ -1317,7 +1712,7 @@ function elevatedExecutor(inner) {
1317
1712
  }
1318
1713
 
1319
1714
  // ../../packages/adapters/src/system/installer.ts
1320
- function log2(message, level = "info") {
1715
+ function log3(message, level = "info") {
1321
1716
  return { timestamp: (/* @__PURE__ */ new Date()).toISOString(), message, level };
1322
1717
  }
1323
1718
  async function prepareExecutor(executor, component) {
@@ -1342,21 +1737,21 @@ async function installDocker(executor, onLog) {
1342
1737
  if (!plan.supported || !plan.installCommand || !plan.verifyCommand) {
1343
1738
  return { component: "docker", success: false, error: plan.unsupportedReason ?? "Docker install not supported" };
1344
1739
  }
1345
- onLog(log2("Installing Docker Engine..."));
1740
+ onLog(log3("Installing Docker Engine..."));
1346
1741
  try {
1347
1742
  const { code } = await executor.streamExec(plan.installCommand, onLog);
1348
1743
  if (code !== 0) return { component: "docker", success: false, error: "Docker install failed" };
1349
1744
  if (plan.startCommand) {
1350
- onLog(log2("Starting Docker service..."));
1745
+ onLog(log3("Starting Docker service..."));
1351
1746
  await executor.streamExec(plan.startCommand, onLog);
1352
1747
  }
1353
1748
  const version = await executor.exec(plan.verifyCommand);
1354
1749
  const parsed = systemCatalog.checks.docker.parseVersion(version);
1355
- onLog(log2(`Docker ${parsed} installed`));
1750
+ onLog(log3(`Docker ${parsed} installed`));
1356
1751
  return { component: "docker", success: true, version: parsed };
1357
1752
  } catch (err) {
1358
1753
  const msg = safeErrorMessage(err);
1359
- onLog(log2(`Docker installation failed: ${msg}`, "error"));
1754
+ onLog(log3(`Docker installation failed: ${msg}`, "error"));
1360
1755
  return { component: "docker", success: false, error: msg };
1361
1756
  }
1362
1757
  }
@@ -1369,17 +1764,17 @@ async function installGit(executor, onLog, opts) {
1369
1764
  if (!plan.supported || !plan.installCommand || !plan.verifyCommand) {
1370
1765
  return { component: "git", success: false, error: plan.unsupportedReason ?? "Git install not supported" };
1371
1766
  }
1372
- onLog(log2(opts?.label ? `Installing Git in ${opts.label}...` : "Installing Git..."));
1767
+ onLog(log3(opts?.label ? `Installing Git in ${opts.label}...` : "Installing Git..."));
1373
1768
  try {
1374
1769
  const { code } = await executor.streamExec(plan.installCommand, onLog);
1375
1770
  if (code !== 0) return { component: "git", success: false, error: "Git installation failed" };
1376
1771
  const version = await executor.exec(plan.verifyCommand);
1377
1772
  const parsed = systemCatalog.checks.git.parseVersion(version);
1378
- onLog(log2(opts?.label ? `Git ${parsed} installed in ${opts.label}` : `Git ${parsed} installed`));
1773
+ onLog(log3(opts?.label ? `Git ${parsed} installed in ${opts.label}` : `Git ${parsed} installed`));
1379
1774
  return { component: "git", success: true, version: parsed };
1380
1775
  } catch (err) {
1381
1776
  const msg = safeErrorMessage(err);
1382
- onLog(log2(`Git installation failed: ${msg}`, "error"));
1777
+ onLog(log3(`Git installation failed: ${msg}`, "error"));
1383
1778
  return { component: "git", success: false, error: msg };
1384
1779
  }
1385
1780
  }
@@ -1392,17 +1787,17 @@ async function installRsync(executor, onLog) {
1392
1787
  if (!plan.supported || !plan.installCommand || !plan.verifyCommand) {
1393
1788
  return { component: "rsync", success: false, error: plan.unsupportedReason ?? "rsync install not supported" };
1394
1789
  }
1395
- onLog(log2("Installing rsync..."));
1790
+ onLog(log3("Installing rsync..."));
1396
1791
  try {
1397
1792
  const { code } = await executor.streamExec(plan.installCommand, onLog);
1398
1793
  if (code !== 0) return { component: "rsync", success: false, error: "rsync installation failed" };
1399
1794
  const version = await executor.exec(plan.verifyCommand);
1400
1795
  const parsed = systemCatalog.checks.rsync.parseVersion(version);
1401
- onLog(log2(`rsync ${parsed} installed`));
1796
+ onLog(log3(`rsync ${parsed} installed`));
1402
1797
  return { component: "rsync", success: true, version: parsed };
1403
1798
  } catch (err) {
1404
1799
  const msg = safeErrorMessage(err);
1405
- onLog(log2(`rsync installation failed: ${msg}`, "error"));
1800
+ onLog(log3(`rsync installation failed: ${msg}`, "error"));
1406
1801
  return { component: "rsync", success: false, error: msg };
1407
1802
  }
1408
1803
  }
@@ -1412,7 +1807,7 @@ async function installContainerEdge(executor, onLog, config) {
1412
1807
  executor = prep.executor;
1413
1808
  if (!await dockerAvailable(executor)) {
1414
1809
  const error = "The edge is a container image and needs Docker on this server. Install the Docker component first, then install the edge.";
1415
- onLog(log2(error, "error"));
1810
+ onLog(log3(error, "error"));
1416
1811
  return { component: "edge", success: false, error };
1417
1812
  }
1418
1813
  try {
@@ -1422,7 +1817,7 @@ async function installContainerEdge(executor, onLog, config) {
1422
1817
  image: config?.edgeImage
1423
1818
  });
1424
1819
  if (result.converted) {
1425
- onLog(log2("Edge moved from host OpenResty to the container edge", "warn"));
1820
+ onLog(log3("Edge moved from host OpenResty to the container edge", "warn"));
1426
1821
  }
1427
1822
  const version = await executor.exec(containerCommand(result.container, "openresty -v 2>&1")).catch(() => "");
1428
1823
  return {
@@ -1433,18 +1828,17 @@ async function installContainerEdge(executor, onLog, config) {
1433
1828
  } catch (err) {
1434
1829
  if (err instanceof EdgeMigrateRequested) throw err;
1435
1830
  const msg = safeErrorMessage(err);
1436
- onLog(log2(`Edge setup failed: ${msg}`, "error"));
1831
+ onLog(log3(`Edge setup failed: ${msg}`, "error"));
1437
1832
  return { component: "edge", success: false, error: msg };
1438
1833
  }
1439
1834
  }
1440
1835
  var COMPONENT_INSTALLERS = {
1441
- docker: (exec, log5) => installDocker(exec, log5),
1442
- // The edge is ONE component and it is a container image. `installOpenResty` /
1443
- // `installCertbot` are NOT reachable from here — they remain only for the mail
1444
- // server's own proxy (mail.service.ts) and for converting a legacy host edge.
1445
- edge: (exec, log5, config) => installContainerEdge(exec, log5, config),
1446
- git: (exec, log5) => installGit(exec, log5),
1447
- rsync: (exec, log5) => installRsync(exec, log5)
1836
+ docker: (exec, log6) => installDocker(exec, log6),
1837
+ // The edge is ONE component and it is a container image there is no
1838
+ // host-OpenResty or host-certbot installer to reach for any more.
1839
+ edge: (exec, log6, config) => installContainerEdge(exec, log6, config),
1840
+ git: (exec, log6) => installGit(exec, log6),
1841
+ rsync: (exec, log6) => installRsync(exec, log6)
1448
1842
  };
1449
1843
 
1450
1844
  // ../../packages/adapters/src/system/available-version.ts
@@ -1572,7 +1966,7 @@ async function tryExec4(executor, command) {
1572
1966
  "checks",
1573
1967
  `exec:ok ${command} (${formatDuration(startedAt)})`
1574
1968
  );
1575
- return result;
1969
+ return { output: result.trim(), error: null };
1576
1970
  } catch (err) {
1577
1971
  if (isRemoteConnectionError(err)) {
1578
1972
  systemDebug(
@@ -1586,9 +1980,15 @@ async function tryExec4(executor, command) {
1586
1980
  "checks",
1587
1981
  `exec:fail ${command} (${formatDuration(startedAt)}) ${msg}`
1588
1982
  );
1589
- return null;
1983
+ return { output: "", error: msg || `\`${command}\` failed` };
1590
1984
  }
1591
1985
  }
1986
+ function withReason(message, error) {
1987
+ if (!error) return message;
1988
+ const first = error.split("\n").map((line) => line.trim()).find(Boolean);
1989
+ if (!first) return message;
1990
+ return `${message} \u2014 ${first.length > 200 ? `${first.slice(0, 199)}\u2026` : first}`;
1991
+ }
1592
1992
  function healthy(name, version, running) {
1593
1993
  const component = getSystemComponentDefinition(name);
1594
1994
  return {
@@ -1621,15 +2021,18 @@ async function checkDocker(executor) {
1621
2021
  const startedAt = Date.now();
1622
2022
  const recipe = systemCatalog.checks.docker;
1623
2023
  const version = await tryExec4(executor, recipe.versionCommand);
1624
- if (!version) {
2024
+ if (!version.output) {
1625
2025
  systemDebug("checks", `docker:missing (${formatDuration(startedAt)})`);
1626
- return unhealthy("docker", recipe.missingMessage);
2026
+ return unhealthy("docker", withReason(recipe.missingMessage, version.error));
1627
2027
  }
1628
- const parsed = recipe.parseVersion(version);
2028
+ const parsed = recipe.parseVersion(version.output);
1629
2029
  const info = await tryExec4(executor, recipe.daemonCommand);
1630
- if (!info) {
1631
- systemDebug("checks", `docker:not-running (${formatDuration(startedAt)})`);
1632
- return unhealthy("docker", recipe.notRunningMessage, {
2030
+ if (!info.output) {
2031
+ systemDebug(
2032
+ "checks",
2033
+ `docker:not-running (${formatDuration(startedAt)}) ${info.error ?? "probe exited 0 with no server version"}`
2034
+ );
2035
+ return unhealthy("docker", withReason(recipe.notRunningMessage, info.error), {
1633
2036
  version: parsed,
1634
2037
  running: false
1635
2038
  });
@@ -1641,11 +2044,11 @@ async function checkGit(executor) {
1641
2044
  const startedAt = Date.now();
1642
2045
  const recipe = systemCatalog.checks.git;
1643
2046
  const version = await tryExec4(executor, recipe.versionCommand);
1644
- if (!version) {
2047
+ if (!version.output) {
1645
2048
  systemDebug("checks", `git:missing (${formatDuration(startedAt)})`);
1646
- return unhealthy("git", recipe.missingMessage);
2049
+ return unhealthy("git", withReason(recipe.missingMessage, version.error));
1647
2050
  }
1648
- const parsed = recipe.parseVersion(version);
2051
+ const parsed = recipe.parseVersion(version.output);
1649
2052
  systemDebug("checks", `git:healthy (${formatDuration(startedAt)})`);
1650
2053
  return healthy("git", parsed);
1651
2054
  }
@@ -1653,11 +2056,11 @@ async function checkRsync(executor) {
1653
2056
  const startedAt = Date.now();
1654
2057
  const recipe = systemCatalog.checks.rsync;
1655
2058
  const version = await tryExec4(executor, recipe.versionCommand);
1656
- if (!version) {
2059
+ if (!version.output) {
1657
2060
  systemDebug("checks", `rsync:missing (${formatDuration(startedAt)})`);
1658
- return unhealthy("rsync", recipe.missingMessage);
2061
+ return unhealthy("rsync", withReason(recipe.missingMessage, version.error));
1659
2062
  }
1660
- const parsed = recipe.parseVersion(version);
2063
+ const parsed = recipe.parseVersion(version.output);
1661
2064
  systemDebug("checks", `rsync:healthy (${formatDuration(startedAt)})`);
1662
2065
  return healthy("rsync", parsed);
1663
2066
  }
@@ -1671,17 +2074,29 @@ async function checkEdge(executor) {
1671
2074
  "No edge on this server. The edge is the openship-edge container \u2014 install it (requires Docker)."
1672
2075
  );
1673
2076
  }
2077
+ const verdict = await verifyEdgeServing(executor, container);
2078
+ if (!verdict.serving) {
2079
+ systemDebug("checks", `edge:not-serving (${formatDuration(startedAt)})`);
2080
+ return unhealthy(
2081
+ "edge",
2082
+ `The edge container ${container} is not serving \u2014 ${verdict.reason ?? "it is not listening on :80"}`,
2083
+ { running: false }
2084
+ );
2085
+ }
1674
2086
  const version = await tryExec4(executor, containerCommand(container, "openresty -v 2>&1"));
1675
- if (!version) {
2087
+ if (!version.output) {
1676
2088
  systemDebug("checks", `edge:container-unresponsive (${formatDuration(startedAt)})`);
1677
2089
  return unhealthy(
1678
2090
  "edge",
1679
- `The edge container ${container} is running but not responding \u2014 check \`docker logs ${container}\``,
2091
+ withReason(
2092
+ `The edge container ${container} is serving on :80 but not answering \u2014 check \`docker logs ${container}\``,
2093
+ version.error
2094
+ ),
1680
2095
  { running: false }
1681
2096
  );
1682
2097
  }
1683
2098
  systemDebug("checks", `edge:healthy (${formatDuration(startedAt)})`);
1684
- return healthy("edge", systemCatalog.checks.openresty.parseVersion(version), true);
2099
+ return healthy("edge", systemCatalog.checks.openresty.parseVersion(version.output), true);
1685
2100
  }
1686
2101
  var COMPONENT_CHECKS = {
1687
2102
  docker: checkDocker,
@@ -1771,24 +2186,36 @@ function describeEdgeOwner(occupants) {
1771
2186
  }
1772
2187
 
1773
2188
  // ../../packages/adapters/src/system/proxy/consent.ts
1774
- function log3(message, level = "info") {
2189
+ function log4(message, level = "info") {
1775
2190
  return { timestamp: (/* @__PURE__ */ new Date()).toISOString(), message, level };
1776
2191
  }
1777
2192
  async function ensureEdgeClear(executor, config, onLog) {
1778
2193
  const status = await probeEdge(executor);
1779
- if (status.canProceedClean) return { tookOver: false };
2194
+ if (status.canProceedClean) return { tookOver: false, status };
1780
2195
  const takeover = async () => {
1781
- onLog(log3(
2196
+ onLog(log4(
1782
2197
  `Taking over ports from ${status.occupants.map((o) => o.command ?? o.port).join(", ")}...`,
1783
2198
  "warn"
1784
2199
  ));
1785
- const configured = config?.edgePolicy?.stopTargets ?? [];
1786
- const targets = configured.length ? configured : stopTargetsForStatus(status);
1787
- await freeEdgeTargets(executor, targets, (m, l) => onLog(log3(m, l)));
2200
+ const freed = await beginEdgeTakeover(
2201
+ executor,
2202
+ status,
2203
+ onLog,
2204
+ config?.edgePolicy?.stopTargets ?? []
2205
+ );
2206
+ if (!freed.freed) {
2207
+ await rollbackEdgeTakeover(executor, onLog);
2208
+ const plural = freed.stillBound.length > 1;
2209
+ throw new AppError(
2210
+ `Stopped the existing proxy, but port${plural ? "s" : ""} ${freed.stillBound.join(" and ")} ${plural ? "are" : "is"} still in use, so the edge can't bind. Nothing was installed and the previous proxy has been restored \u2014 find what else is holding the port and retry.`,
2211
+ 409,
2212
+ "EDGE_PORTS_STILL_BOUND"
2213
+ );
2214
+ }
1788
2215
  };
1789
2216
  if (config?.edgePolicy?.mode === "takeover") {
1790
2217
  await takeover();
1791
- return { tookOver: true };
2218
+ return { tookOver: true, status };
1792
2219
  }
1793
2220
  if (config?.promptUser) {
1794
2221
  const known = status.classification === "known";
@@ -1817,7 +2244,7 @@ async function ensureEdgeClear(executor, config, onLog) {
1817
2244
  }
1818
2245
  if (action === "override") {
1819
2246
  await takeover();
1820
- return { tookOver: true };
2247
+ return { tookOver: true, status };
1821
2248
  }
1822
2249
  throw new EdgeConflictError(status);
1823
2250
  }
@@ -1825,7 +2252,7 @@ async function ensureEdgeClear(executor, config, onLog) {
1825
2252
  }
1826
2253
 
1827
2254
  // ../../packages/adapters/src/system/proxy/ensure-container-edge.ts
1828
- function log4(message, level = "info") {
2255
+ function log5(message, level = "info") {
1829
2256
  return { timestamp: (/* @__PURE__ */ new Date()).toISOString(), message, level };
1830
2257
  }
1831
2258
  var injectedDefaultImage;
@@ -1835,15 +2262,8 @@ function setDefaultEdgeImage(image) {
1835
2262
  function resolveEdgeImage(explicit) {
1836
2263
  return buildEdgeImageRef({ explicit, injectedDefault: injectedDefaultImage });
1837
2264
  }
1838
- async function dockerAvailable(executor) {
1839
- return executor.exec("docker version --format '{{.Server.Version}}' 2>/dev/null").then((v) => Boolean(v.trim())).catch(() => false);
1840
- }
1841
- async function runningImage(executor, container) {
1842
- const out = await executor.exec(`docker inspect -f '{{.Config.Image}}' ${sq(container)} 2>/dev/null`).catch(() => "");
1843
- return out.trim() || null;
1844
- }
1845
2265
  async function containerEdgeProvider(executor, container, opts) {
1846
- const { NginxProvider: Provider } = await import("./nginx-3L4SIBJA.js");
2266
+ const { NginxProvider: Provider } = await import("./nginx-AXGNWRKZ.js");
1847
2267
  return new Provider({
1848
2268
  ...opts,
1849
2269
  // Vhosts go to the BIND-MOUNTED HOST dir (so every host-side reader — the
@@ -1851,21 +2271,33 @@ async function containerEdgeProvider(executor, container, opts) {
1851
2271
  // while reload/certbot run inside the container.
1852
2272
  paths: EDGE_HOST_PATHS,
1853
2273
  executor: edgeContainerExecutor(executor, container),
2274
+ // Challenge tokens go to the HOST side of the ACME mount, for the same reason
2275
+ // vhosts do. This is the ONE mount whose two sides differ
2276
+ // (/var/lib/openship/edge/acme → /var/www/acme), so the write path and the
2277
+ // vhost's `root` are genuinely different strings — `root` stays the container
2278
+ // path. Overridden here and nowhere else, next to the `paths` override it
2279
+ // mirrors, so the two cannot drift.
2280
+ challengeDir: EDGE_CHALLENGE_HOST_DIR,
1854
2281
  // MUST be pinned: re-detection answers from inside the container and would
1855
2282
  // repoint sitesDir at a host dir the edge never reads.
1856
- pinPaths: true
2283
+ pinPaths: true,
2284
+ // Reload runs INSIDE the container, where the master is pid 1 — so a failed
2285
+ // reload must never fall back to killing it (#292).
2286
+ containerEdge: true
1857
2287
  });
1858
2288
  }
1859
2289
  async function localContainerEdgeProvider(container, opts) {
1860
- const { DockerEdgeExecutor } = await import("./docker-edge-executor-VHW3JPJS.js");
1861
- const { NginxProvider: Provider } = await import("./nginx-3L4SIBJA.js");
2290
+ const { DockerEdgeExecutor } = await import("./docker-edge-executor-62N64MGS.js");
2291
+ const { NginxProvider: Provider } = await import("./nginx-AXGNWRKZ.js");
1862
2292
  return new Provider({
1863
2293
  ...opts,
1864
2294
  paths: OPENRESTY_DEFAULT_PATHS,
1865
2295
  executor: new DockerEdgeExecutor({ containerName: container }),
1866
2296
  // nginx.conf + the Lua are BAKED into the image — nothing to detect, install
1867
2297
  // or patch, and detection would answer from inside the container anyway.
1868
- pinPaths: true
2298
+ pinPaths: true,
2299
+ // `docker exec`s into the edge, so the master is pid 1 there too (#292).
2300
+ containerEdge: true
1869
2301
  });
1870
2302
  }
1871
2303
  async function startEdgeContainer(executor, container, image, onLog) {
@@ -1894,37 +2326,39 @@ function buildEdgeRunCommand(container, image) {
1894
2326
  sq(image)
1895
2327
  ].join(" ");
1896
2328
  }
1897
- async function swapEdgeImage(executor, container, from, to, opts) {
2329
+ function makeEdgeStart(executor, container, opts) {
1898
2330
  const { onLog } = opts;
1899
- onLog(log4(`Updating the edge: ${from} \u2192 ${to}`));
1900
- const pull = await executor.streamExec(`docker pull ${sq(to)}`, onLog);
1901
- if (pull.code !== 0) {
1902
- onLog(log4(`Could not pull ${to} \u2014 the edge stays on ${from}.`, "warn"));
1903
- return { swapped: false, edgeDown: false };
1904
- }
1905
- const start = async (image) => {
2331
+ return async (image) => {
1906
2332
  if (!await startEdgeContainer(executor, container, image, onLog)) return false;
1907
- const listening = await waitForPortListening(executor, 80, {
2333
+ const verdict = await verifyEdgeServing(executor, container, {
1908
2334
  timeoutMs: opts.verifyTimeoutMs ?? 3e4
1909
2335
  });
1910
- return !(listening.checked && !listening.listening);
2336
+ if (!verdict.serving && verdict.reason) onLog(log5(`Edge ${image}: ${verdict.reason}`, "warn"));
2337
+ return verdict.serving;
1911
2338
  };
1912
- if (await start(to)) {
1913
- onLog(log4(`Edge updated to ${to}`));
1914
- return { swapped: true, edgeDown: false };
2339
+ }
2340
+ async function verifyEdgeServing(executor, container, opts = {}) {
2341
+ if (await edgeIsBroken(executor, container)) {
2342
+ const reason = await edgeCrashReason(executor, container);
2343
+ return { serving: false, reason: reason ?? "the edge container is not running" };
1915
2344
  }
1916
- onLog(log4(`Edge ${to} failed to come up \u2014 rolling back to ${from}...`, "error"));
1917
- if (await start(from)) {
1918
- return { swapped: false, edgeDown: false };
2345
+ if (opts.configTest) {
2346
+ try {
2347
+ await executor.exec(containerCommand(container, "openresty -t 2>&1"));
2348
+ } catch (err) {
2349
+ const out = safeErrorMessage(err);
2350
+ return { serving: false, reason: edgeFailureReason(out) ?? out };
2351
+ }
1919
2352
  }
1920
- onLog(log4(`Rollback to ${from} ALSO failed \u2014 the edge is down on this server.`, "error"));
1921
- return { swapped: false, edgeDown: true };
1922
- }
1923
- async function listDir(executor, dir) {
1924
- const out = await executor.exec(`ls -1 ${sq(dir)} 2>/dev/null || true`).catch(() => "");
1925
- return new Set(
1926
- out.split("\n").map((l) => l.trim()).filter(Boolean)
1927
- );
2353
+ const listening = await waitForPortListening(executor, 80, { timeoutMs: opts.timeoutMs ?? 0 });
2354
+ if (listening.checked && !listening.listening) {
2355
+ const reason = await edgeCrashReason(executor, container);
2356
+ return {
2357
+ serving: false,
2358
+ reason: reason ?? "the edge container is up but nothing is listening on :80"
2359
+ };
2360
+ }
2361
+ return { serving: true };
1928
2362
  }
1929
2363
  async function ensureContainerEdge(executor, opts) {
1930
2364
  const { onLog } = opts;
@@ -1932,123 +2366,112 @@ async function ensureContainerEdge(executor, opts) {
1932
2366
  const image = resolveEdgeImage(opts.image);
1933
2367
  const existing = await resolveOurEdgeContainer(executor, { fresh: true });
1934
2368
  if (existing) {
1935
- const current = await runningImage(executor, existing);
1936
- if (current && current !== image) {
1937
- const swap = await swapEdgeImage(executor, existing, current, image, opts);
1938
- return {
1939
- container: existing,
1940
- image,
1941
- converted: false,
1942
- updated: swap.swapped,
1943
- edgeDown: swap.edgeDown
1944
- };
2369
+ const verdict = await verifyEdgeServing(executor, existing);
2370
+ if (!verdict.serving) {
2371
+ onLog(log5(`The edge container exists but isn't serving: ${verdict.reason}`, "warn"));
2372
+ onLog(log5("Recreating it...", "warn"));
2373
+ } else {
2374
+ const current = await containerImageRef(executor, existing);
2375
+ const stale = current != null && current !== image;
2376
+ if (stale) {
2377
+ const start = makeEdgeStart(executor, existing, opts);
2378
+ const swap = await swapManagedImage(executor, {
2379
+ kind: "edge",
2380
+ from: current ?? image,
2381
+ to: image,
2382
+ label: "edge",
2383
+ onLog,
2384
+ start
2385
+ });
2386
+ return {
2387
+ container: existing,
2388
+ image,
2389
+ converted: false,
2390
+ updated: swap.swapped,
2391
+ edgeDown: swap.down
2392
+ };
2393
+ }
2394
+ return { container: existing, image, converted: false };
1945
2395
  }
1946
- return { container: existing, image, converted: false };
1947
2396
  }
1948
- await ensureEdgeClear(executor, opts.config, onLog);
1949
2397
  if (!await dockerAvailable(executor)) {
1950
2398
  throw new Error(
1951
2399
  "Docker isn't available on this server, and the edge now runs as a container. Install the Docker component first, then set up the edge."
1952
2400
  );
1953
2401
  }
1954
- const bareWasOurs = await ourLuaOnHost(executor);
1955
- onLog(log4(`Pulling edge image ${image}...`));
1956
- const pull = await executor.streamExec(`docker pull ${sq(image)}`, onLog);
1957
- if (pull.code !== 0) {
1958
- throw new Error(
1959
- `Could not pull the edge image ${image} \u2014 the box is unchanged and still serving. Check the server's network access to the registry, then retry.`
1960
- );
2402
+ if (!await imageExistsLocally(executor, image)) {
2403
+ if (managedImagesAreFromSource("edge")) {
2404
+ throw new Error(fromSourceImageMissingMessage("edge", image));
2405
+ }
2406
+ onLog(log5(`Pulling edge image ${image}...`));
2407
+ const pull = await executor.streamExec(`docker pull ${sq(image)}`, onLog);
2408
+ if (pull.code !== 0) {
2409
+ throw new Error(
2410
+ `Could not pull the edge image ${image} \u2014 the box is unchanged and still serving. Check the server's network access to the registry, then retry.`
2411
+ );
2412
+ }
1961
2413
  }
1962
2414
  for (const mount of EDGE_CONTAINER_MOUNTS) {
1963
2415
  await executor.exec(`mkdir -p ${sq(mount.host)}`).catch(() => {
1964
2416
  });
1965
2417
  }
1966
- const sitesTarget = EDGE_HOST_PATHS.sitesDir;
1967
- let beforeCarry = null;
1968
- if (bareWasOurs) {
1969
- const barePaths = await detectOpenRestyPaths(executor).catch(() => OPENRESTY_DEFAULT_PATHS);
1970
- if (barePaths.sitesDir !== sitesTarget && await executor.exists(barePaths.sitesDir)) {
1971
- onLog(log4(`Carrying vhosts from ${barePaths.sitesDir}...`));
1972
- beforeCarry = await listDir(executor, sitesTarget);
1973
- await executor.exec(`cp -a ${sq(`${barePaths.sitesDir}/.`)} ${sq(`${sitesTarget}/`)} 2>/dev/null || true`).catch(() => {
1974
- });
1975
- }
1976
- }
1977
- const restoreBare = async () => {
1978
- if (!bareWasOurs) return;
1979
- if (beforeCarry) {
1980
- const now = await listDir(executor, sitesTarget);
1981
- const added = [...now].filter((name) => !beforeCarry.has(name));
1982
- if (added.length > 0) {
1983
- await executor.exec(`rm -f ${added.map((n) => sq(`${sitesTarget}/${n}`)).join(" ")}`).catch(() => {
1984
- });
1985
- onLog(log4(`Removed ${added.length} carried vhost(s) so the next edge start is clean.`, "warn"));
1986
- }
1987
- }
1988
- onLog(log4("Restoring the host OpenResty edge...", "warn"));
1989
- await executor.exec("systemctl enable --now openresty 2>/dev/null || true").catch(() => {
1990
- });
1991
- };
2418
+ const clear = await ensureEdgeClear(executor, opts.config, onLog);
1992
2419
  try {
1993
- if (bareWasOurs) {
1994
- onLog(log4("Stopping the host OpenResty edge..."));
1995
- await executor.exec("systemctl disable --now openresty 2>/dev/null || true").catch(() => {
1996
- });
1997
- await executor.exec("systemctl reset-failed openresty 2>/dev/null || true").catch(() => {
1998
- });
1999
- }
2000
- onLog(log4("Starting the edge container..."));
2420
+ onLog(log5("Starting the edge container..."));
2001
2421
  if (!await startEdgeContainer(executor, container, image, onLog)) {
2002
2422
  throw new Error("the edge container failed to start");
2003
2423
  }
2004
- await executor.exec(containerCommand(container, "openresty -t"));
2005
- const listening = await waitForPortListening(executor, 80, {
2424
+ const verdict = await verifyEdgeServing(executor, container, {
2425
+ configTest: true,
2006
2426
  timeoutMs: opts.verifyTimeoutMs ?? 3e4
2007
2427
  });
2008
- if (listening.checked && !listening.listening) {
2009
- throw new Error("the edge container started but nothing is listening on :80");
2428
+ if (!verdict.serving) {
2429
+ throw new Error(verdict.reason ?? "the edge container started but isn't serving");
2010
2430
  }
2431
+ if (clear.tookOver) await completeEdgeTakeover(executor);
2011
2432
  const version = await executor.exec(containerCommand(container, "openresty -v 2>&1")).catch(() => "");
2012
- onLog(log4(`Edge container running${version.trim() ? ` (${version.trim()})` : ""}`));
2013
- return { container, image, converted: bareWasOurs };
2433
+ onLog(log5(`Edge container running${version.trim() ? ` (${version.trim()})` : ""}`));
2434
+ return { container, image, converted: clear.tookOver };
2014
2435
  } catch (err) {
2015
2436
  const msg = safeErrorMessage(err);
2016
- onLog(log4(`Edge container setup failed: ${msg}`, "error"));
2437
+ onLog(log5(`Edge container setup failed: ${msg}`, "error"));
2017
2438
  const logs = await executor.exec(`docker logs --tail 40 ${sq(container)} 2>&1 || true`).catch(() => "");
2018
- if (logs.trim()) onLog(log4(`Edge container logs:
2439
+ if (logs.trim()) onLog(log5(`Edge container logs:
2019
2440
  ${logs}`, "error"));
2020
2441
  await executor.exec(`docker rm -f ${sq(container)} 2>/dev/null || true`).catch(() => {
2021
2442
  });
2022
2443
  invalidateEdgeContainer(executor);
2023
- await restoreBare();
2444
+ if (clear.tookOver) await rollbackEdgeTakeover(executor, onLog);
2024
2445
  const reason = edgeFailureReason(logs);
2025
2446
  throw new Error(`Edge container setup failed: ${reason ? `${reason} (${msg})` : msg}`);
2026
2447
  }
2027
2448
  }
2028
2449
 
2029
2450
  export {
2030
- systemCatalog,
2031
- checkGit,
2032
- checkAll,
2033
- checkComponents,
2451
+ beginEdgeTakeover,
2452
+ rollbackEdgeTakeover,
2453
+ completeEdgeTakeover,
2454
+ recoverInterruptedTakeover,
2034
2455
  detectInstalledProxy,
2035
2456
  scanImportableSites,
2457
+ unreachableStaticRoots,
2458
+ copyStaticRootIntoEdge,
2036
2459
  edgeProxy,
2037
2460
  edgeProxyFor,
2038
2461
  collectProxyCerts,
2039
- beginEdgeTakeover,
2040
- rollbackEdgeTakeover,
2041
- completeEdgeTakeover,
2042
- recoverInterruptedTakeover,
2462
+ systemCatalog,
2463
+ installGit,
2464
+ COMPONENT_INSTALLERS,
2465
+ importSites,
2466
+ foreignProxyOnEdge,
2043
2467
  setDefaultEdgeImage,
2044
2468
  resolveEdgeImage,
2045
- dockerAvailable,
2046
2469
  containerEdgeProvider,
2047
2470
  localContainerEdgeProvider,
2048
2471
  buildEdgeRunCommand,
2472
+ verifyEdgeServing,
2049
2473
  ensureContainerEdge,
2050
- importSites,
2051
- foreignProxyOnEdge,
2052
- installGit,
2053
- COMPONENT_INSTALLERS
2474
+ checkGit,
2475
+ checkAll,
2476
+ checkComponents
2054
2477
  };