diff --git a/lib/common/services/net-service.ts b/lib/common/services/net-service.ts index d519265ab6..2c08ee218c 100644 --- a/lib/common/services/net-service.ts +++ b/lib/common/services/net-service.ts @@ -48,35 +48,32 @@ export class Net implements INet { } public async isPortAvailable(port: number): Promise { - return new Promise((resolve, reject) => { - let isResolved = false; - const server = net.createServer(); + // A listener on one address does not stop a bind on another (e.g. the + // wildcard next to the loopback), so each of them has to be free. + for (const host of ["127.0.0.1", "::1", "0.0.0.0", "::"]) { + if (!(await this.canListen(port, host))) { + return false; + } + } - server.on("error", (err: Error) => { - if (!isResolved) { - isResolved = true; - resolve(false); - } - }); + return true; + } - server.once("close", () => { - if (!isResolved) { - // "close" will be emitted right after "error" - isResolved = true; - resolve(true); - } - }); + private canListen(port: number, host: string): Promise { + return new Promise((resolve) => { + const server = net.createServer(); - server.on("listening", (err: Error) => { - if (err && !isResolved) { - isResolved = true; - resolve(true); - } + server.once("error", (err: NodeJS.ErrnoException) => { + // The host has no such address (e.g. IPv6 is disabled), so nothing + // can be listening on it either. + resolve(err.code === "EADDRNOTAVAIL" || err.code === "EAFNOSUPPORT"); + }); - server.close(); + server.once("listening", () => { + server.close(() => resolve(true)); }); - server.listen(port, "localhost"); + server.listen(port, host); }); } diff --git a/lib/common/test/unit-tests/services/net-service.ts b/lib/common/test/unit-tests/services/net-service.ts index 9d35773158..73751f1a00 100644 --- a/lib/common/test/unit-tests/services/net-service.ts +++ b/lib/common/test/unit-tests/services/net-service.ts @@ -1,5 +1,6 @@ import { Net } from "../../../services/net-service"; import { assert } from "chai"; +import * as net from "net"; import { Yok } from "../../../yok"; import { ErrorsStub, CommonLoggerStub } from "../stubs"; import { EOL } from "os"; @@ -25,6 +26,43 @@ describe("net", () => { return testInjector; }; + describe("isPortAvailable", () => { + const hosts = [undefined, "127.0.0.1", "::1", "0.0.0.0", "::"]; + + for (const host of hosts) { + it(`returns false for a port taken on ${host || "the default address"}`, async (context: any) => { + const service = createTestInjector("darwin").resolve(Net); + const server = net.createServer(); + const listening = await new Promise((resolve) => { + server.once("error", () => resolve(false)); + const onListening = () => resolve(true); + host ? server.listen(0, host, onListening) : server.listen(0, onListening); + }); + if (!listening) { + context.skip(); + return; + } + const { port } = server.address(); + + try { + assert.isFalse(await service.isPortAvailable(port)); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + } + + it("returns true for a free port", async () => { + const service = createTestInjector("darwin").resolve(Net); + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, () => resolve())); + const { port } = server.address(); + await new Promise((resolve) => server.close(resolve)); + + assert.isTrue(await service.isPortAvailable(port)); + }); + }); + describe("waitForPortToListen", () => { let execCalledCount = 0; beforeEach(() => { diff --git a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts index 10ddd50209..832d53f9b1 100644 --- a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts +++ b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts @@ -8,6 +8,36 @@ import { IDictionary, IErrors, INet } from "../../common/declarations"; import { injector } from "../../common/yok"; import { ITempService } from "../../definitions/temp-service"; +const REPLACED_CLOSE_REASON = "Replaced by a newer debugger client"; + +/** + * The debugger can run any code in the app, and any web page can try to open a + * WebSocket to this port. Clients that aren't browsers, like VS Code's debug + * adapter, send no origin. + */ +export function isAllowedDebuggerOrigin(origin?: string): boolean { + if (!origin) { + return true; + } + + try { + const { protocol, hostname, origin: urlOrigin } = new URL(origin); + return ( + protocol === "devtools:" || + protocol === "chrome-devtools:" || + urlOrigin === "https://chrome-devtools-frontend.appspot.com" || + ["localhost", "127.0.0.1", "[::1]"].includes(hostname) + ); + } catch { + return false; + } +} + +interface IDebugProxySession { + appDebugSocket: net.Socket; + webSocket?: ws; +} + export class AppDebugSocketProxyFactory extends EventEmitter implements IAppDebugSocketProxyFactory @@ -18,7 +48,6 @@ export class AppDebugSocketProxyFactory constructor( private $logger: ILogger, private $errors: IErrors, - private $lockService: ILockService, private $options: IOptions, private $tempService: ITempService, private $net: INet, @@ -132,7 +161,6 @@ export class AppDebugSocketProxyFactory projectName: string, projectDir: string, ): Promise { - let clientConnectionLockRelease: () => void; const cacheKey = `${device.deviceInfo.identifier}-${appId}`; const existingServer = this.deviceWebServers[cacheKey]; if (existingServer) { @@ -141,76 +169,92 @@ export class AppDebugSocketProxyFactory ); } - // NOTE: We will try to provide command line options to select ports, at least on the localhost. - const localPort = await this.$net.getAvailablePortInRange(41000); - this.$logger.info( "\nSetting up debugger proxy...\nPress Ctrl + C to terminate, or disconnect.\n", ); // NB: When the inspector frontend connects we might not have connected to the inspector backend yet. // That's why we use the verifyClient callback of the websocket server to stall the upgrade request until we connect. - // We store the socket that connects us to the device in the upgrade request object itself and later on retrieve it - // in the connection callback. - - let currentAppSocket: net.Socket = null; - let currentWebSocket: ws = null; - const server = new ws.Server({ - port: localPort, - verifyClient: async ( + // We store the session in the upgrade request object itself and later on retrieve it in the connection callback. + + // The runtime serves a single frontend, so the newest client takes over. + // Handlers of a replaced session must leave the device socket alone, as + // by then it belongs to the session that replaced it. + let currentSession: IDebugProxySession = null; + let pendingConnect: Promise = Promise.resolve(); + + const connectToApp = async (): Promise => { + const previousSession = currentSession; + currentSession = null; + if (previousSession) { + previousSession.webSocket?.close(1000, REPLACED_CLOSE_REASON); + await device.destroyDebugSocket(appId); + } + + const appDebugSocket = await device.getDebugSocket( + appId, + projectName, + projectDir, + ); + appDebugSocket.on("error", (err: Error) => { + this.$logger.trace("Error on debugger deviceSocket", err); + }); + this.$logger.info("Backend socket created."); + + currentSession = { appDebugSocket }; + return currentSession; + }; + + const servers = await this.listenOnAvailablePort(41000, { + verifyClient: ( info: any, callback: (res: boolean, code?: number, message?: string) => void, ) => { - let acceptHandshake = true; - clientConnectionLockRelease = null; - - try { - clientConnectionLockRelease = await this.$lockService.lock( - `debug-connection-${device.deviceInfo.identifier}-${appId}.lock`, - ); - - this.$logger.info("Frontend client connected."); - let appDebugSocket; - if (currentAppSocket) { - currentAppSocket.removeAllListeners(); - currentAppSocket = null; - if (currentWebSocket) { - currentWebSocket.removeAllListeners(); - currentWebSocket.close(); - currentWebSocket = null; - } - await device.destroyDebugSocket(appId); - } - appDebugSocket = await device.getDebugSocket( - appId, - projectName, - projectDir, - ); - currentAppSocket = appDebugSocket; - this.$logger.info("Backend socket created."); - info.req["__deviceSocket"] = appDebugSocket; - } catch (err) { - if (clientConnectionLockRelease) { - clientConnectionLockRelease(); - } - err.deviceIdentifier = device.deviceInfo.identifier; - this.$logger.trace(err); - this.emit(CONNECTION_ERROR_EVENT_NAME, err); - acceptHandshake = false; + if (!isAllowedDebuggerOrigin(info.origin)) { this.$logger.warn( - `Cannot connect to device socket. The error message is '${err.message}'.`, + `Rejected a debugger connection from origin '${info.origin}'.`, ); + callback(false, 403, "Origin not allowed"); + return; } - callback(acceptHandshake); + this.$logger.info("Frontend client connected."); + + // Not released on "connection": that never fires for a client that + // drops during the handshake. + pendingConnect = pendingConnect.then(async () => { + try { + info.req["__session"] = await connectToApp(); + callback(true); + } catch (err) { + err.deviceIdentifier = device.deviceInfo.identifier; + this.$logger.trace(err); + this.emit(CONNECTION_ERROR_EVENT_NAME, err); + this.$logger.warn( + `Cannot connect to device socket. The error message is '${err.message}'.`, + ); + callback(false); + } + }); }, }); + const server = servers[0]; this.deviceWebServers[cacheKey] = server; - server.on("connection", (webSocket, req) => { - currentWebSocket = webSocket; + const onConnection = (webSocket: ws, req: any) => { const encoding = "utf16le"; - const appDebugSocket: net.Socket = (req)["__deviceSocket"]; + webSocket.on("error", (err) => { + this.$logger.trace("Error on debugger websocket", err); + }); + + const session: IDebugProxySession = (req)["__session"]; + if (session !== currentSession || session.appDebugSocket.destroyed) { + webSocket.close(1000, REPLACED_CLOSE_REASON); + return; + } + + session.webSocket = webSocket; + const appDebugSocket = session.appDebugSocket; const packets = new MessageUnpackStream(); appDebugSocket.pipe(packets); @@ -230,14 +274,6 @@ export class AppDebugSocketProxyFactory } }); - webSocket.on("error", (err) => { - this.$logger.trace("Error on debugger websocket", err); - }); - - appDebugSocket.on("error", (err) => { - this.$logger.trace("Error on debugger deviceSocket", err); - }); - webSocket.on("message", (message) => { const msg = message.toString(); if (process.env.DEBUG_DEVTOOLS_SOCKETS) { @@ -253,28 +289,88 @@ export class AppDebugSocketProxyFactory }); appDebugSocket.on("close", () => { - currentAppSocket = null; this.$logger.trace("Backend socket closed!"); webSocket.close(); }); webSocket.on("close", async () => { - currentWebSocket = null; this.$logger.trace("Frontend socket closed!"); appDebugSocket.unpipe(packets); packets.destroy(); + if (currentSession !== session) { + return; + } + + currentSession = null; await device.destroyDebugSocket(appId); if (!this.$options.watch) { process.exit(0); } }); - - clientConnectionLockRelease(); + }; + for (const listeningServer of servers) { + listeningServer.on("connection", onConnection); + } + server.once("close", () => { + servers.slice(1).forEach((other) => other.close()); }); return server; } + private async listenOnAvailablePort( + startPort: number, + options: ws.ServerOptions, + ): Promise { + // The port can still get taken after the check, by another CLI for example. + let port = await this.$net.getAvailablePortInRange(startPort); + while (true) { + const servers: ws.Server[] = []; + try { + // Only reachable from this machine. Clients told to use "localhost" + // may end up on either loopback address. + servers.push(await this.listen("127.0.0.1", port, options)); + const ipv6Server = await this.listen("::1", port, options).catch( + (err: NodeJS.ErrnoException): ws.Server => { + if (err.code === "EADDRINUSE") { + throw err; + } + this.$logger.trace("Not listening on ::1", err); + return null; + }, + ); + if (ipv6Server) { + servers.push(ipv6Server); + } + return servers; + } catch (err) { + servers.forEach((server) => server.close()); + if (err.code !== "EADDRINUSE") { + throw err; + } + port = await this.$net.getAvailablePortInRange(port + 1); + } + } + } + + private listen( + host: string, + port: number, + options: ws.ServerOptions, + ): Promise { + return new Promise((resolve, reject) => { + const server = new ws.Server({ ...options, host, port }); + server.once("listening", () => { + server.removeAllListeners("error"); + server.on("error", (err: Error) => { + this.$logger.trace("Error on debugger web socket server", err); + }); + resolve(server); + }); + server.once("error", reject); + }); + } + public removeAllProxies() { let deviceId; for (deviceId in this.deviceWebServers) { diff --git a/lib/services/ios-debugger-port-service.ts b/lib/services/ios-debugger-port-service.ts index ca1c1c5a40..4146453219 100644 --- a/lib/services/ios-debugger-port-service.ts +++ b/lib/services/ios-debugger-port-service.ts @@ -143,8 +143,8 @@ export class IOSDebuggerPortService implements IIOSDebuggerPortService { private clearTimeout(data: IIOSDebuggerPortData): void { const storedData = this.mapDebuggerPortData[`${data.deviceId}${data.appId}`]; - if (storedData && typeof storedData.timer === "number") { - clearTimeout(storedData.timer); + if (storedData && storedData.timer) { + clearTimeout(storedData.timer); } } } diff --git a/test/device-sockets/app-debug-socket-proxy-factory.ts b/test/device-sockets/app-debug-socket-proxy-factory.ts new file mode 100644 index 0000000000..30d28473d2 --- /dev/null +++ b/test/device-sockets/app-debug-socket-proxy-factory.ts @@ -0,0 +1,324 @@ +import { assert } from "chai"; +import * as net from "net"; +import * as os from "os"; +import * as sinon from "sinon"; +import * as ws from "ws"; +import { MessageUnpackStream } from "ios-device-lib"; +import { + AppDebugSocketProxyFactory, + isAllowedDebuggerOrigin, +} from "../../lib/device-sockets/ios/app-debug-socket-proxy-factory"; + +const REPLACED_CLOSE_REASON = "Replaced by a newer debugger client"; + +const encode = (message: string): Buffer => { + const length = Buffer.byteLength(message, "utf16le"); + const payload = Buffer.allocUnsafe(length + 4); + payload.writeInt32BE(length, 0); + payload.write(message, 4, length, "utf16le"); + return payload; +}; + +const hasIPv6Loopback = () => + Object.values(os.networkInterfaces()) + .flat() + .some((address) => address.internal && address.family === "IPv6"); + +describe("AppDebugSocketProxyFactory", () => { + let backend: net.Server; + let backendPort: number; + let backendConnections: number; + let proxy: any; + let proxies: ws.Server[]; + let device: any; + + // Echoes every framed message, as the runtime answers each CDP request. + const startBackend = () => + new Promise((resolve) => { + backend = net.createServer((socket) => { + backendConnections++; + const packets = new MessageUnpackStream(); + socket.pipe(packets); + packets.on("data", (buffer: Buffer) => { + socket.write(encode(buffer.toString("utf16le"))); + }); + socket.on("error", () => {}); + }); + backend.listen(0, "127.0.0.1", () => { + backendPort = (backend.address()).port; + resolve(); + }); + }); + + const createDevice = () => { + const cachedSockets: { [appId: string]: net.Socket } = {}; + return { + deviceInfo: { identifier: "device" }, + getDebugSocket: async (appId: string) => { + if (!cachedSockets[appId]) { + cachedSockets[appId] = net.connect(backendPort, "127.0.0.1"); + } + return cachedSockets[appId]; + }, + destroyDebugSocket: async (appId: string) => { + cachedSockets[appId]?.destroy(); + cachedSockets[appId] = null; + }, + }; + }; + + const createProxy = ( + getAvailablePortInRange?: () => Promise, + watch = true, + ) => + new AppDebugSocketProxyFactory( + { info: () => {}, trace: () => {}, warn: () => {} }, + { fail: (message: string) => assert.fail(message) }, + { watch }, + {}, + { + getAvailablePortInRange: + getAvailablePortInRange || + (async () => { + const server = net.createServer(); + await new Promise((r) => server.listen(0, () => r())); + const { port } = server.address(); + await new Promise((r) => server.close(r)); + return port; + }), + }, + ); + + const startProxy = async (factory: any = proxy) => { + const server: ws.Server = await factory.ensureWebSocketProxy( + device, + "app", + "project", + "/project", + ); + proxies.push(server); + return (server.address()).port; + }; + + const open = (port: number, options?: ws.ClientOptions, host = "127.0.0.1") => + new Promise((resolve, reject) => { + const client = new ws(`ws://${host}:${port}`, options); + client.once("open", () => resolve(client)); + client.once("error", reject); + }); + + const assertRejects = async (promise: Promise) => { + const client = await promise.catch((): ws => null); + assert.isNull(client); + }; + + const roundTrip = (client: ws, message: string) => + new Promise((resolve, reject) => { + client.once("message", (data) => resolve(data.toString())); + client.once("close", () => reject(new Error("closed before the reply"))); + client.send(message); + }); + + const closeReason = (client: ws) => + new Promise((resolve) => { + client.once("close", (_code, reason) => resolve(reason.toString())); + }); + + beforeEach(async () => { + backendConnections = 0; + proxies = []; + await startBackend(); + device = createDevice(); + proxy = createProxy(); + }); + + afterEach(async () => { + await device.destroyDebugSocket("app"); + proxies.forEach((server) => { + server.clients.forEach((client) => client.terminate()); + server.close(); + }); + await new Promise((resolve) => backend.close(resolve)); + }); + + it("moves to the next port when another server takes the checked one", async () => { + const blocker = net.createServer(); + await new Promise((resolve) => + blocker.listen(0, "127.0.0.1", () => resolve()), + ); + const blockedPort = (blocker.address()).port; + const ports = [blockedPort]; + const factory = createProxy(async () => { + if (ports.length) { + return ports.shift(); + } + const server = net.createServer(); + await new Promise((r) => server.listen(0, () => r())); + const { port } = server.address(); + await new Promise((r) => server.close(r)); + return port; + }); + + const port = await startProxy(factory); + await new Promise((resolve) => blocker.close(resolve)); + + assert.notEqual(port, blockedPort); + const client = await open(port); + assert.equal(await roundTrip(client, "hello"), "hello"); + }); + + it("listens on both loopback addresses and nothing else", async () => { + const port = await startProxy(); + + const hosts = ["127.0.0.1"]; + if (hasIPv6Loopback()) { + hosts.push("[::1]"); + } + for (const host of hosts) { + const client = await open(port, undefined, host); + assert.equal(await roundTrip(client, host), host); + } + + const addresses = Object.values(os.networkInterfaces()) + .flat() + .filter((address) => !address.internal && address.family === "IPv4"); + for (const { address } of addresses) { + await assertRejects(open(port, undefined, address)); + } + }); + + it("turns away web pages but not DevTools or clients without an origin", async () => { + const port = await startProxy(); + + await assertRejects(open(port, { origin: "https://evil.example" })); + assert.equal(backendConnections, 0); + + const devtools = await open(port, { origin: "devtools://devtools" }); + assert.equal(await roundTrip(devtools, "hello"), "hello"); + const noOrigin = await open(port); + assert.equal(await roundTrip(noOrigin, "hello"), "hello"); + }); + + it("serves a client after the previous one disconnected", async () => { + const port = await startProxy(); + for (let i = 0; i < 3; i++) { + const client = await open(port); + assert.equal(await roundTrip(client, `message ${i}`), `message ${i}`); + client.close(); + } + + const client = await open(port); + assert.equal(await roundTrip(client, "last"), "last"); + }); + + it("still serves clients after one drops during the handshake", async () => { + const port = await startProxy(); + await new Promise((resolve) => { + const socket = net.connect(port, "127.0.0.1", () => { + socket.write( + "GET / HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + ); + setImmediate(() => { + socket.destroy(); + resolve(); + }); + }); + }); + + const client = await open(port); + assert.equal(await roundTrip(client, "hello"), "hello"); + }); + + it("gives the backend to the newest client and tells the previous one why", async () => { + const port = await startProxy(); + const first = await open(port); + const firstClosed = closeReason(first); + + const second = await open(port); + + assert.equal(await firstClosed, REPLACED_CLOSE_REASON); + assert.equal(await roundTrip(second, "hello"), "hello"); + assert.equal(backendConnections, 2); + }); + + it("cleans up and exits without watch when the app goes away", async () => { + const exit = sinon.stub(process, "exit"); + const destroyDebugSocket = sinon.spy(device, "destroyDebugSocket"); + try { + const port = await startProxy(createProxy(undefined, false)); + const client = await open(port); + const closed = closeReason(client); + const appSocket = await device.getDebugSocket("app"); + + appSocket.destroy(); + await closed; + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.isTrue(destroyDebugSocket.called); + assert.isTrue(exit.calledWith(0)); + } finally { + exit.restore(); + destroyDebugSocket.restore(); + } + }); + + it("keeps the new client's backend when the replaced client closes late", async () => { + const port = await startProxy(); + const first = await open(port); + const opening = open(port); + first.terminate(); + + const second = await opening; + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.equal(second.readyState, ws.OPEN); + assert.equal(await roundTrip(second, "hello"), "hello"); + }); + + it("leaves only the newest of simultaneous clients connected", async () => { + const port = await startProxy(); + const clients = await Promise.all( + [0, 1, 2, 3].map(() => open(port).catch((): ws => null)), + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const connected = clients.filter( + (client) => client && client.readyState === ws.OPEN, + ); + assert.lengthOf(connected, 1); + assert.equal(await roundTrip(connected[0], "hello"), "hello"); + }); + + describe("isAllowedDebuggerOrigin", () => { + const allowed = [ + undefined, + "", + "devtools://devtools", + "chrome-devtools://devtools", + "https://chrome-devtools-frontend.appspot.com", + "http://localhost:5173", + "http://127.0.0.1:8080", + "http://[::1]:3000", + ]; + for (const origin of allowed) { + it(`allows ${origin || "no origin"}`, () => { + assert.isTrue(isAllowedDebuggerOrigin(origin)); + }); + } + + const rejected = [ + "https://evil.example", + "http://localhost.evil.example", + "http://127.0.0.1.evil.example", + "https://chrome-devtools-frontend.appspot.com.evil.example", + "null", + "file://", + ]; + for (const origin of rejected) { + it(`rejects ${origin}`, () => { + assert.isFalse(isAllowedDebuggerOrigin(origin)); + }); + } + }); +});