|
16 | 16 |
|
17 | 17 | import { spawn, spawnSync } from "node:child_process"; |
18 | 18 | import { request as httpRequest } from "node:http"; |
| 19 | +import { connect } from "node:net"; |
19 | 20 | import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; |
20 | | -import { tmpdir } from "node:os"; |
| 21 | +import { networkInterfaces, tmpdir } from "node:os"; |
21 | 22 | import { join } from "node:path"; |
22 | 23 | import { checkReplitConfig, checkStarterPackage } from "./replit-config.mjs"; |
23 | 24 |
|
@@ -106,6 +107,31 @@ async function requirePortFree(port) { |
106 | 107 | }); |
107 | 108 | } |
108 | 109 |
|
| 110 | +/// The first non-internal IPv4 address of this machine, or undefined when it |
| 111 | +/// has none (an offline laptop, a network-less container). |
| 112 | +function externalIPv4() { |
| 113 | + for (const addrs of Object.values(networkInterfaces())) { |
| 114 | + for (const a of addrs ?? []) { |
| 115 | + if (a.family === "IPv4" && !a.internal) return a.address; |
| 116 | + } |
| 117 | + } |
| 118 | + return undefined; |
| 119 | +} |
| 120 | + |
| 121 | +/// Whether something accepts TCP connections on host:port. |
| 122 | +function canConnect(host, port) { |
| 123 | + return new Promise((resolve) => { |
| 124 | + const socket = connect({ host, port: Number(port), timeout: 3000 }); |
| 125 | + const done = (ok) => { |
| 126 | + socket.destroy(); |
| 127 | + resolve(ok); |
| 128 | + }; |
| 129 | + socket.once("connect", () => done(true)); |
| 130 | + socket.once("error", () => done(false)); |
| 131 | + socket.once("timeout", () => done(false)); |
| 132 | + }); |
| 133 | +} |
| 134 | + |
109 | 135 | /// Fetch the preview, retrying while the app-port proxy is still coming up. |
110 | 136 | /// The proxy binds asynchronously after the editor answers on its own port, so |
111 | 137 | /// a single immediate request races it and sees a 502. |
@@ -235,14 +261,28 @@ try { |
235 | 261 |
|
236 | 262 | const root = await waitForBoot(); |
237 | 263 | check(`editor answered on ${BASE}`, true); |
238 | | - // Match the address, not the sentence around it: the legacy path prints |
239 | | - // "running at http://0.0.0.0:<port>" and hosted mode prints it in an indented |
240 | | - // "local:" row. Pinning either phrasing tests the banner, not the bind. |
241 | | - check( |
242 | | - "bound to 0.0.0.0, not just loopback", |
243 | | - new RegExp(`0\\.0\\.0\\.0:${PORT}`).test(log), |
244 | | - `no 0.0.0.0:${PORT} in the startup output`, |
245 | | - ); |
| 264 | + // Test the bind itself, not the banner: a loopback-only bind refuses a |
| 265 | + // connection addressed to this machine's external interface. The banner is |
| 266 | + // only a fallback for machines with no such interface, and even then it is |
| 267 | + // waited for -- the editor prints it after it starts answering, so reading |
| 268 | + // the log the moment the first request succeeds races it (CI lost that race). |
| 269 | + const external = externalIPv4(); |
| 270 | + if (external) { |
| 271 | + check( |
| 272 | + "bound to 0.0.0.0, not just loopback", |
| 273 | + await canConnect(external, PORT), |
| 274 | + `connection to ${external}:${PORT} was refused`, |
| 275 | + ); |
| 276 | + } else { |
| 277 | + const banner = new RegExp(`0\\.0\\.0\\.0:${PORT}`); |
| 278 | + const deadline = Date.now() + 10_000; |
| 279 | + while (!banner.test(log) && Date.now() < deadline) await sleep(250); |
| 280 | + check( |
| 281 | + "bound to 0.0.0.0, not just loopback", |
| 282 | + banner.test(log), |
| 283 | + `no external interface to probe, and no 0.0.0.0:${PORT} in the startup output`, |
| 284 | + ); |
| 285 | + } |
246 | 286 |
|
247 | 287 | // 5. GET / issues the session cookie on the HTML document. |
248 | 288 | console.log("\nSession cookie"); |
|
0 commit comments