From c4c51a369294aa0fcf20ff5f3b89a7b2797ccb92 Mon Sep 17 00:00:00 2001 From: Volodymyr Kolesnykov Date: Fri, 1 May 2026 19:28:52 +0300 Subject: [PATCH] feat: automatically update `hosts` file for failed domain --- .../lib/dev-environment/hosts-updater.spec.ts | 384 ++++++++++++++++++ src/bin/vip-dev-env-start.js | 75 ++-- .../dev-environment/dev-environment-core.ts | 5 +- .../dev-environment/dev-environment-lando.ts | 117 +++++- src/lib/dev-environment/hosts-updater.ts | 221 ++++++++++ 5 files changed, 759 insertions(+), 43 deletions(-) create mode 100644 __tests__/lib/dev-environment/hosts-updater.spec.ts create mode 100644 src/lib/dev-environment/hosts-updater.ts diff --git a/__tests__/lib/dev-environment/hosts-updater.spec.ts b/__tests__/lib/dev-environment/hosts-updater.spec.ts new file mode 100644 index 000000000..cf28d5899 --- /dev/null +++ b/__tests__/lib/dev-environment/hosts-updater.spec.ts @@ -0,0 +1,384 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import fetch from 'node-fetch'; +import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { createWriteStream } from 'node:fs'; +import { access, mkdir, mkdtemp, rename, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +jest.mock( 'node-fetch' ); +jest.mock( '../../../src/lib/http/proxy-agent', () => ( { + createProxyAgent: jest.fn( () => null ), +} ) ); +jest.mock( 'node:child_process', () => ( { + ...jest.requireActual< object >( 'node:child_process' ), + spawn: jest.fn(), +} ) ); +jest.mock( 'node:fs', () => ( { + ...jest.requireActual< object >( 'node:fs' ), + createWriteStream: jest.fn(), + rmSync: jest.fn(), +} ) ); +jest.mock( 'node:fs/promises', () => ( { + ...jest.requireActual< object >( 'node:fs/promises' ), + access: jest.fn(), + mkdir: jest.fn(), + mkdtemp: jest.fn(), + rename: jest.fn(), + rm: jest.fn(), +} ) ); +jest.mock( 'node:stream/promises', () => ( { + ...jest.requireActual< object >( 'node:stream/promises' ), + pipeline: jest.fn(), +} ) ); + +import { + DownloadError, + InvalidChecksumError, + download, + getExeName, + getInstallDir, + getReleaseUrl, + installBinary, + updateDomains, +} from '../../../src/lib/dev-environment/hosts-updater'; + +const mockedFetch = jest.mocked( fetch ); +const mockedSpawn = jest.mocked( spawn ); +const mockedCreateWriteStream = jest.mocked( createWriteStream ); +const mockedAccess = jest.mocked( access ); +const mockedMkdir = jest.mocked( mkdir ); +const mockedMkdtemp = jest.mocked( mkdtemp ); +const mockedRename = jest.mocked( rename ); +const mockedRm = jest.mocked( rm ); +const mockedPipeline = jest.mocked( pipeline ); + +describe( 'getReleaseUrl', () => { + it( 'returns latest download URLs when version is "latest"', () => { + const [ binary, checksum ] = getReleaseUrl( 'latest', 'x64', 'linux' ); + expect( binary ).toBe( + 'https://github.com/Automattic/dev-env-update-hosts/releases/latest/download/dev-env-update-hosts-linux-amd64.gz' + ); + expect( checksum ).toBe( `${ binary }.sum` ); + } ); + + it( 'returns versioned download URLs when a specific version is given', () => { + const [ binary, checksum ] = getReleaseUrl( 'v1.2.3', 'x64', 'linux' ); + expect( binary ).toBe( + 'https://github.com/Automattic/dev-env-update-hosts/releases/download/v1.2.3/dev-env-update-hosts-linux-amd64.gz' + ); + expect( checksum ).toBe( `${ binary }.sum` ); + } ); + + it( 'adds .exe suffix on windows', () => { + const [ binary ] = getReleaseUrl( 'v1.0.0', 'x64', 'win32' ); + expect( binary ).toMatch( /\.exe\.gz$/ ); + } ); + + it( 'maps arm64 architecture correctly', () => { + const [ binary ] = getReleaseUrl( 'latest', 'arm64', 'darwin' ); + expect( binary ).toContain( 'darwin-arm64' ); + } ); + + it( 'maps ia32 architecture to 386', () => { + const [ binary ] = getReleaseUrl( 'latest', 'ia32' as NodeJS.Architecture, 'linux' ); + expect( binary ).toContain( 'linux-386' ); + } ); + + it( 'throws for unsupported architecture', () => { + expect( () => getReleaseUrl( 'latest', 'mips' as NodeJS.Architecture, 'linux' ) ).toThrow( + 'Unsupported platform or architecture' + ); + } ); + + it( 'throws for unsupported platform', () => { + expect( () => getReleaseUrl( 'latest', 'x64', 'freebsd' as NodeJS.Platform ) ).toThrow( + 'Unsupported platform or architecture' + ); + } ); +} ); + +describe( 'getExeName', () => { + it( 'returns arch-specific name on linux', () => { + expect( getExeName( 'linux', 'x64' ) ).toBe( 'dev-env-update-host-linux-x64' ); + } ); + + it( 'returns arch-specific name on darwin', () => { + expect( getExeName( 'darwin', 'arm64' ) ).toBe( 'dev-env-update-host-darwin-arm64' ); + } ); + + it( 'returns arch-specific .exe name on win32', () => { + expect( getExeName( 'win32', 'x64' ) ).toBe( 'dev-env-update-host-win32-x64.exe' ); + } ); +} ); + +describe( 'download', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'returns text when asText is true', async () => { + mockedFetch.mockResolvedValue( { + ok: true, + text: jest.fn< () => Promise< string > >().mockResolvedValue( 'checksum-value' ), + } as unknown as Awaited< ReturnType< typeof fetch > > ); + + const result = await download( new URL( 'https://example.com/file.sum' ), true ); + expect( result ).toBe( 'checksum-value' ); + } ); + + it( 'returns a readable stream when asText is false', async () => { + const stream = Readable.from( [ Buffer.from( 'data' ) ] ); + mockedFetch.mockResolvedValue( { + ok: true, + body: stream, + } as unknown as Awaited< ReturnType< typeof fetch > > ); + + const result = await download( new URL( 'https://example.com/file.gz' ), false ); + expect( result ).toBe( stream ); + } ); + + it( 'throws DownloadError when response is not ok', async () => { + mockedFetch.mockResolvedValue( { + ok: false, + status: 404, + } as unknown as Awaited< ReturnType< typeof fetch > > ); + + await expect( + download( new URL( 'https://example.com/missing.gz' ), false ) + ).rejects.toBeInstanceOf( DownloadError ); + } ); + + it( 'DownloadError includes the status code', async () => { + mockedFetch.mockResolvedValue( { + ok: false, + status: 403, + } as unknown as Awaited< ReturnType< typeof fetch > > ); + + await expect( + download( new URL( 'https://example.com/forbidden.gz' ), false ) + ).rejects.toThrow( '403' ); + } ); + + it( 'aborts when the timeout elapses', async () => { + jest.useFakeTimers(); + + mockedFetch.mockImplementation( + ( _url: unknown, opts?: unknown ) => + new Promise< Awaited< ReturnType< typeof fetch > > >( ( _resolve, reject ) => { + const { signal } = opts as { signal: AbortSignal }; + signal.addEventListener( 'abort', () => + reject( new DOMException( 'Aborted', 'AbortError' ) ) + ); + } ) + ); + + const promise = download( new URL( 'https://example.com/slow.gz' ), false, 100 ); + jest.advanceTimersByTime( 200 ); + await expect( promise ).rejects.toThrow(); + + jest.useRealTimers(); + } ); +} ); + +describe( 'installBinary', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'downloads, validates checksum, and renames the file on success', async () => { + // sha256 of empty string — pipeline is mocked to no-op so no data flows through the hash + const emptyHash = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + + mockedFetch + .mockResolvedValueOnce( { + ok: true, + text: jest.fn< () => Promise< string > >().mockResolvedValue( `${ emptyHash } file\n` ), + } as unknown as Awaited< ReturnType< typeof fetch > > ) + .mockResolvedValueOnce( { + ok: true, + body: Readable.from( [] ), + } as unknown as Awaited< ReturnType< typeof fetch > > ); + + mockedCreateWriteStream.mockReturnValue( {} as ReturnType< typeof createWriteStream > ); + mockedPipeline.mockResolvedValue(); + mockedRename.mockResolvedValue(); + + const dest = '/tmp/test-dir'; + const expectedBinary = path.join( dest, 'dev-env-update-host-linux-x64' ); + const result = await installBinary( 'v1.0.0', dest, 0, 'x64', 'linux' ); + expect( result ).toBe( expectedBinary ); + // Temp file has a random suffix; capture the actual name from the createWriteStream call + const tempFile = mockedCreateWriteStream.mock.calls[ 0 ][ 0 ] as string; + expect( tempFile ).toMatch( /dev-env-update-host-linux-x64\.[0-9a-f]+\.tmp$/ ); + expect( mockedRename ).toHaveBeenCalledWith( tempFile, expectedBinary ); + } ); + + it( 'removes the temp file and throws InvalidChecksumError when checksum does not match', async () => { + const wrongChecksum = 'a'.repeat( 64 ); + + mockedFetch + .mockResolvedValueOnce( { + ok: true, + text: jest + .fn< () => Promise< string > >() + .mockResolvedValue( `${ wrongChecksum } file\n` ), + } as unknown as Awaited< ReturnType< typeof fetch > > ) + .mockResolvedValueOnce( { + ok: true, + body: Readable.from( [] ), + } as unknown as Awaited< ReturnType< typeof fetch > > ); + + mockedCreateWriteStream.mockReturnValue( {} as ReturnType< typeof createWriteStream > ); + mockedPipeline.mockResolvedValue(); + mockedRm.mockResolvedValue(); + mockedRename.mockResolvedValue(); + + await expect( + installBinary( 'v1.0.0', '/tmp/test-dir', 0, 'x64', 'linux' ) + ).rejects.toBeInstanceOf( InvalidChecksumError ); + + // Temp file (with random suffix) must be cleaned up on checksum failure + const tempFile = mockedCreateWriteStream.mock.calls[ 0 ][ 0 ] as string; + expect( mockedRm ).toHaveBeenCalledWith( tempFile, { force: true } ); + // Rename must NOT have been called + expect( mockedRename ).not.toHaveBeenCalled(); + } ); + + it( 'removes the temp file when pipeline fails', async () => { + mockedFetch + .mockResolvedValueOnce( { + ok: true, + text: jest.fn< () => Promise< string > >().mockResolvedValue( 'abc123 file\n' ), + } as unknown as Awaited< ReturnType< typeof fetch > > ) + .mockResolvedValueOnce( { + ok: true, + body: Readable.from( [] ), + } as unknown as Awaited< ReturnType< typeof fetch > > ); + + mockedCreateWriteStream.mockReturnValue( {} as ReturnType< typeof createWriteStream > ); + mockedPipeline.mockRejectedValue( new Error( 'network reset' ) ); + mockedRm.mockResolvedValue(); + + await expect( installBinary( 'v1.0.0', '/tmp/test-dir', 0, 'x64', 'linux' ) ).rejects.toThrow( + 'network reset' + ); + + // Temp file (with random suffix) must be cleaned up when pipeline fails + const tempFile = mockedCreateWriteStream.mock.calls[ 0 ][ 0 ] as string; + expect( tempFile ).toMatch( /dev-env-update-host-linux-x64\.[0-9a-f]+\.tmp$/ ); + expect( mockedRm ).toHaveBeenCalledWith( tempFile, { force: true } ); + expect( mockedRename ).not.toHaveBeenCalled(); + } ); + + it( 'throws when the binary download returns null body', async () => { + mockedFetch + .mockResolvedValueOnce( { + ok: true, + text: jest.fn< () => Promise< string > >().mockResolvedValue( 'abc123' ), + } as unknown as Awaited< ReturnType< typeof fetch > > ) + .mockResolvedValueOnce( { + ok: true, + body: null, + } as unknown as Awaited< ReturnType< typeof fetch > > ); + + await expect( installBinary( 'v1.0.0', '/tmp/test-dir', 0, 'x64', 'linux' ) ).rejects.toThrow( + 'Failed to download binary' + ); + // No temp file should have been created + expect( mockedCreateWriteStream ).not.toHaveBeenCalled(); + } ); + + it( 'throws DownloadError when checksum download fails', async () => { + mockedFetch.mockResolvedValueOnce( { + ok: false, + status: 503, + } as unknown as Awaited< ReturnType< typeof fetch > > ); + + await expect( + installBinary( 'v1.0.0', '/tmp/test-dir', 0, 'x64', 'linux' ) + ).rejects.toBeInstanceOf( DownloadError ); + } ); +} ); + +describe( 'getInstallDir', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'returns the bin directory when it is writable', async () => { + mockedMkdir.mockResolvedValue( undefined ); + mockedAccess.mockResolvedValue(); + + const dir = await getInstallDir(); + expect( dir ).toMatch( /[\\/]bin$/ ); + } ); + + it( 'falls back to a temp dir when mkdir fails', async () => { + mockedMkdir.mockRejectedValue( new Error( 'EACCES' ) ); + mockedAccess.mockRejectedValue( new Error( 'EACCES' ) ); + mockedMkdtemp.mockResolvedValue( '/tmp/dev-env-update-hosts-abc123' ); + + const dir = await getInstallDir(); + expect( dir ).toBe( '/tmp/dev-env-update-hosts-abc123' ); + expect( mockedMkdtemp ).toHaveBeenCalled(); + } ); + + it( 'falls back to a temp dir when the bin directory is not writable', async () => { + mockedMkdir.mockResolvedValue( undefined ); + mockedAccess.mockRejectedValue( + Object.assign( new Error( 'EACCES: permission denied' ), { code: 'EACCES' } ) + ); + mockedMkdtemp.mockResolvedValue( '/tmp/dev-env-update-hosts-xyz789' ); + + const dir = await getInstallDir(); + expect( dir ).toBe( '/tmp/dev-env-update-hosts-xyz789' ); + } ); +} ); + +describe( 'updateDomains', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + function makeChildProcess( exitCode: number | null, errorEvent?: Error ) { + const emitter = new EventEmitter(); + setImmediate( () => { + if ( errorEvent ) { + emitter.emit( 'error', errorEvent ); + } else { + emitter.emit( 'exit', exitCode ); + } + } ); + return emitter; + } + + it( 'resolves when the binary exits with code 0', async () => { + mockedSpawn.mockReturnValue( makeChildProcess( 0 ) as unknown as ReturnType< typeof spawn > ); + + await expect( + updateDomains( '/usr/local/bin/updater', [ 'example.lndo.site' ] ) + ).resolves.toBeUndefined(); + } ); + + it( 'rejects when the binary exits with a non-zero code', async () => { + mockedSpawn.mockReturnValue( makeChildProcess( 1 ) as unknown as ReturnType< typeof spawn > ); + + await expect( + updateDomains( '/usr/local/bin/updater', [ 'example.lndo.site' ] ) + ).rejects.toThrow( 'Binary exited with code 1' ); + } ); + + it( 'rejects when the child process emits an error', async () => { + const error = new Error( 'ENOENT: no such file or directory' ); + mockedSpawn.mockReturnValue( + makeChildProcess( null, error ) as unknown as ReturnType< typeof spawn > + ); + + await expect( + updateDomains( '/usr/local/bin/updater', [ 'example.lndo.site' ] ) + ).rejects.toThrow( 'ENOENT: no such file or directory' ); + } ); +} ); diff --git a/src/bin/vip-dev-env-start.js b/src/bin/vip-dev-env-start.js index 476b37c0b..cb5cadaf8 100755 --- a/src/bin/vip-dev-env-start.js +++ b/src/bin/vip-dev-env-start.js @@ -52,7 +52,7 @@ const examples = [ }, ]; -command( { +const cmd = command( { usage, } ) .option( @@ -73,43 +73,52 @@ command( { .option( 'editor', 'Generate a workspace file for the specified editor (supports: vscode, cursor, windsurf, phpstorm).' - ) - .examples( examples ) - .argv( process.argv, async ( arg, opt ) => { - const slug = await getEnvironmentName( opt ); - const lando = await bootstrapLando( { logFile: getDevEnvLogFile( slug ) } ); - validateDependencies( lando ); + ); + +if ( process.platform !== 'win32' ) { + cmd.option( + 'autofix-domain-resolution', + 'Automatically fix domain resolution issues by updating the hosts file [UNSAFE].' + ); +} + +cmd.examples( examples ).argv( process.argv, async ( arg, opt ) => { + const slug = await getEnvironmentName( opt ); + const lando = await bootstrapLando( { logFile: getDevEnvLogFile( slug ) } ); + validateDependencies( lando ); + + const startProcessing = new Date(); - const startProcessing = new Date(); + const trackingInfo = getEnvTrackingInfo( slug ); + trackingInfo.editor = opt.editor || ( opt.vscode ? 'vscode' : undefined ); + trackingInfo.vscode = Boolean( opt.vscode ); + trackingInfo.docker = lando.config.versions.engine; + trackingInfo.docker_compose = lando.config.versions.compose; + trackingInfo.compose_plugin = lando.config.versions.composePlugin; - const trackingInfo = getEnvTrackingInfo( slug ); - trackingInfo.editor = opt.editor || ( opt.vscode ? 'vscode' : undefined ); - trackingInfo.vscode = Boolean( opt.vscode ); - trackingInfo.docker = lando.config.versions.engine; - trackingInfo.docker_compose = lando.config.versions.compose; - trackingInfo.compose_plugin = lando.config.versions.composePlugin; + await trackEvent( 'dev_env_start_command_execute', trackingInfo ); - await trackEvent( 'dev_env_start_command_execute', trackingInfo ); + debug( 'Args: ', arg, 'Options: ', opt ); - debug( 'Args: ', arg, 'Options: ', opt ); + const options = { + skipRebuild: Boolean( opt.skipRebuild ), + skipWpVersionsCheck: Boolean( opt.skipWpVersionsCheck ), + autofixDomains: Boolean( opt.autofixDomainResolution ), + }; - const options = { - skipRebuild: Boolean( opt.skipRebuild ), - skipWpVersionsCheck: Boolean( opt.skipWpVersionsCheck ), - }; - try { - await startEnvironment( lando, slug, options ); + try { + await startEnvironment( lando, slug, options ); - const processingTime = Math.ceil( ( new Date() - startProcessing ) / 1000 ); // in seconds - const successTrackingInfo = { ...trackingInfo, processing_time: processingTime }; - await trackEvent( 'dev_env_start_command_success', successTrackingInfo ); - } catch ( error ) { - await handleCLIException( error, 'dev_env_start_command_error', trackingInfo ); - process.exitCode = 1; - } + const processingTime = Math.ceil( ( new Date() - startProcessing ) / 1000 ); // in seconds + const successTrackingInfo = { ...trackingInfo, processing_time: processingTime }; + await trackEvent( 'dev_env_start_command_success', successTrackingInfo ); + } catch ( error ) { + await handleCLIException( error, 'dev_env_start_command_error', trackingInfo ); + process.exitCode = 1; + } - postStart( slug, { - editor: opt.editor, - vscode: Boolean( opt.vscode ), - } ); + postStart( slug, { + editor: opt.editor, + vscode: Boolean( opt.vscode ), } ); +} ); diff --git a/src/lib/dev-environment/dev-environment-core.ts b/src/lib/dev-environment/dev-environment-core.ts index 5a1d4db75..10b50f0c6 100644 --- a/src/lib/dev-environment/dev-environment-core.ts +++ b/src/lib/dev-environment/dev-environment-core.ts @@ -89,6 +89,7 @@ const integrationsConfigPathString = 'integrations-config'; interface StartEnvironmentOptions { skipRebuild: boolean; skipWpVersionsCheck: boolean; + autofixDomains: boolean; } interface WordPressTag { @@ -136,9 +137,9 @@ export async function startEnvironment( } if ( options.skipRebuild && ! updated ) { - await landoStart( lando, instancePath ); + await landoStart( lando, instancePath, options.autofixDomains ); } else { - await landoRebuild( lando, instancePath ); + await landoRebuild( lando, instancePath, options.autofixDomains ); } await printEnvironmentInfo( lando, slug, { extended: false } ); diff --git a/src/lib/dev-environment/dev-environment-lando.ts b/src/lib/dev-environment/dev-environment-lando.ts index f566d1408..46dd12664 100644 --- a/src/lib/dev-environment/dev-environment-lando.ts +++ b/src/lib/dev-environment/dev-environment-lando.ts @@ -8,7 +8,7 @@ import landoUtils, { type AppInfo } from 'lando/plugins/lando-core/lib/utils'; import landoBuildTask from 'lando/plugins/lando-tooling/lib/build'; import { execFile } from 'node:child_process'; import { lookup } from 'node:dns/promises'; -import { mkdir, rename, unlink, stat, writeFile } from 'node:fs/promises'; +import { mkdir, rename, unlink, stat, writeFile, access, constants } from 'node:fs/promises'; import { cpus, totalmem, userInfo } from 'node:os'; import path, { dirname } from 'node:path'; import { promisify } from 'node:util'; @@ -26,6 +26,7 @@ import { DEV_ENVIRONMENT_NOT_FOUND } from '../constants/dev-environment'; import env from '../env'; import UserError from '../user-error'; import { xdgData } from '../xdg-data'; +import { getExeName, getInstallDir, installBinary, updateDomains } from './hosts-updater'; import type { NetworkInspectInfo } from 'dockerode'; import type Landerode from 'lando/lib/docker'; @@ -431,12 +432,44 @@ export async function bootstrapLando( options: LandoBootstrapOptions = {} ): Pro } } -export async function landoStart( lando: Lando, instancePath: string ): Promise< void > { +const autofixedApps = new WeakSet< App >(); + +function installDnsAutofixer( app: App ): void { + if ( autofixedApps.has( app ) ) { + return; + } + + autofixedApps.add( app ); + app.events.on( 'post-start', 9, async () => { + const urlsToScan: string[] = []; + app.info + .filter( service => service.urls.length ) + .forEach( service => { + service.urls.forEach( url => { + if ( ! /^https?:\/\/(localhost|127\.0\.0\.1):/.exec( url ) && ! url.includes( '*' ) ) { + urlsToScan.push( url ); + } + } ); + } ); + + await tryResolveDomains( urlsToScan, true ); + } ); +} + +export async function landoStart( + lando: Lando, + instancePath: string, + autoFixDomainResolution = false +): Promise< void > { const started = new Date(); try { debug( 'Will start lando app on path:', instancePath ); const app = await getLandoApplication( lando, instancePath ); + if ( autoFixDomainResolution ) { + installDnsAutofixer( app ); + } + await app.start(); } finally { const duration = new Date().getTime() - started.getTime(); @@ -470,12 +503,19 @@ export async function landoLogs( lando: Lando, instancePath: string, options: La } } -export async function landoRebuild( lando: Lando, instancePath: string ): Promise< void > { +export async function landoRebuild( + lando: Lando, + instancePath: string, + autoFixDomainResolution = false +): Promise< void > { const started = new Date(); try { debug( 'Will rebuild lando app on path:', instancePath ); const app = await getLandoApplication( lando, instancePath ); + if ( autoFixDomainResolution ) { + installDnsAutofixer( app ); + } await ensureNoOrphantProxyContainer( lando ); await app.rebuild(); @@ -699,7 +739,7 @@ async function getExtraServicesConnections( return extraServices; } -async function tryResolveDomains( urls: string[] ): Promise< void > { +export async function tryResolveDomains( urls: string[], autofix: boolean ): Promise< void > { const domains = [ ...new Set( urls @@ -716,6 +756,7 @@ async function tryResolveDomains( urls: string[] ): Promise< void > { ]; const domainsToFix: string[] = []; + const pendingWarnings: string[] = []; for ( const domain of domains ) { try { // eslint-disable-next-line no-await-in-loop @@ -724,19 +765,37 @@ async function tryResolveDomains( urls: string[] ): Promise< void > { if ( address.address !== '127.0.0.1' ) { domainsToFix.push( domain ); - console.warn( - chalk.yellow.bold( 'WARNING:' ), + pendingWarnings.push( `${ domain } resolves to ${ address.address } instead of 127.0.0.1. Things may not work as expected.` ); } } catch ( err ) { const msg = err instanceof Error ? err.message : 'Unknown error'; domainsToFix.push( domain ); - console.warn( chalk.yellow.bold( 'WARNING:' ), `Failed to resolve ${ domain }: ${ msg }` ); + pendingWarnings.push( `Failed to resolve ${ domain }: ${ msg }` ); } } if ( domainsToFix.length ) { + if ( autofix ) { + console.log( chalk.green( 'Attempting to fix domain resolution issues...' ) ); + const result = await autofixDomains( domainsToFix ); + if ( result instanceof Error ) { + // Autofix failed — surface the original DNS warnings so the user knows what went wrong + pendingWarnings.forEach( msg => console.warn( chalk.yellow.bold( 'WARNING:' ), msg ) ); + console.error( chalk.red( result.message ) ); + if ( result.cause ) { + console.error( chalk.red( 'Cause: ' ), ( result.cause as Error ).message ); + } + } else { + // Autofix succeeded — suppress the warnings so a clean start looks clean + console.log( chalk.green( 'Domain resolution issues fixed successfully.' ) ); + return; + } + } else { + pendingWarnings.forEach( msg => console.warn( chalk.yellow.bold( 'WARNING:' ), msg ) ); + } + console.warn( chalk.yellow( 'Please add the following lines to the hosts file on your system:\n' ) ); @@ -749,6 +808,48 @@ async function tryResolveDomains( urls: string[] ): Promise< void > { } } +function ensureError( error: unknown ): Error { + if ( error instanceof Error ) { + return error; + } + + return new Error( String( error ) ); +} + +const AUTOFIX_DOWNLOAD_TIMEOUT_MS = 30_000; + +async function autofixDomains( domains: string[] ): Promise< true | Error > { + const dir = await getInstallDir(); + const filename = getExeName(); + let binary: string = path.join( dir, filename ); + + try { + await access( binary, constants.X_OK ); + } catch { + try { + binary = await installBinary( 'latest', dir, AUTOFIX_DOWNLOAD_TIMEOUT_MS ); + } catch ( err: unknown ) { + return new Error( + 'Failed to install hosts updater binary, cannot autofix domain resolution issues.', + { cause: ensureError( err ) } + ); + } + } + + const fixableDomains = domains.filter( domain => ! domain.includes( '*' ) ); + if ( fixableDomains.length ) { + try { + await updateDomains( binary, fixableDomains ); + } catch ( err ) { + return new Error( 'Failed to update hosts file, cannot autofix domain resolution issues.', { + cause: ensureError( err ), + } ); + } + } + + return true; +} + async function getRunningServicesForProject( docker: Landerode, project: string @@ -784,7 +885,7 @@ export async function checkEnvHealth( } ); const urlsToScan = Object.keys( urls ).filter( url => ! url.includes( '*' ) ); - await tryResolveDomains( urlsToScan ); + await tryResolveDomains( urlsToScan, false ); app.urls.forEach( entry => { // We use different status codes to see if the service is up. // We may consider the service is up when Lando considers it is down. diff --git a/src/lib/dev-environment/hosts-updater.ts b/src/lib/dev-environment/hosts-updater.ts new file mode 100644 index 000000000..56e791cdd --- /dev/null +++ b/src/lib/dev-environment/hosts-updater.ts @@ -0,0 +1,221 @@ +import fetch from 'node-fetch'; +import { spawn } from 'node:child_process'; +import { BinaryLike, createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { createWriteStream, rmSync } from 'node:fs'; +import { access, constants, mkdir, mkdtemp, rename, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { format } from 'node:util'; +import { createGunzip } from 'node:zlib'; + +import { createProxyAgent } from '../http/proxy-agent'; + +export class DownloadError extends Error { + constructor( url: URL, code: number, options?: ErrorOptions ) { + super( format( 'Failed to download file: %s (status code: %d)', url, code ), options ); + this.name = 'DownloadError'; + } +} + +export class InvalidChecksumError extends Error { + constructor( message?: string, options?: ErrorOptions ) { + super( message, options ); + this.name = 'InvalidChecksumError'; + } +} + +const archMap: Record< string, string > = { + ia32: '386', + x64: 'amd64', + arm64: 'arm64', +}; + +const platformMap: Record< string, string > = { + win32: 'windows', + darwin: 'darwin', + linux: 'linux', +}; + +export function getReleaseUrl( + version = 'latest', + arch: NodeJS.Architecture = process.arch, + platform: NodeJS.Platform = process.platform +): [ string, string ] { + const resolvedArch = archMap[ arch ]; + const resolvedPlatform = platformMap[ platform ]; + + if ( ! resolvedArch || ! resolvedPlatform ) { + throw new Error( 'Unsupported platform or architecture' ); + } + + const suffix = 'windows' === resolvedPlatform ? '.exe' : ''; + + if ( version !== 'latest' ) { + const binary = `https://github.com/Automattic/dev-env-update-hosts/releases/download/${ version }/dev-env-update-hosts-${ resolvedPlatform }-${ resolvedArch }${ suffix }.gz`; + const checksum = `${ binary }.sum`; + return [ binary, checksum ]; + } + + const binary = `https://github.com/Automattic/dev-env-update-hosts/releases/latest/download/dev-env-update-hosts-${ resolvedPlatform }-${ resolvedArch }${ suffix }.gz`; + const checksum = `${ binary }.sum`; + return [ binary, checksum ]; +} + +export async function download( url: URL, asText: true, timeout?: number ): Promise< string >; +export async function download( + url: URL, + asText: false, + timeout?: number +): Promise< NodeJS.ReadableStream | null >; +export async function download( + url: URL, + asText: boolean, + timeout = 0 +): Promise< NodeJS.ReadableStream | string | null > { + const controller = new AbortController(); + const timeoutId = timeout > 0 ? setTimeout( () => controller.abort(), timeout ) : null; + const clearTimer = () => { + if ( timeoutId ) { + clearTimeout( timeoutId ); + } + }; + const proxyAgent = createProxyAgent( url.toString() ); + + let response: Awaited< ReturnType< typeof fetch > >; + try { + response = await fetch( url, { + signal: controller.signal, + redirect: 'follow', + agent: proxyAgent ?? undefined, + } ); + } catch ( err ) { + clearTimer(); + throw err; + } + + if ( ! response.ok ) { + clearTimer(); + throw new DownloadError( url, response.status ); + } + + if ( asText ) { + try { + return await response.text(); + } finally { + clearTimer(); + } + } + + // For streams: unref the timer so it will not prevent process exit once the + // body has been fully consumed, but it will still abort a stalled read while + // the event loop is kept alive by the active stream pipeline. + timeoutId?.unref(); + return response.body; +} + +export function getExeName( + platform: NodeJS.Platform = process.platform, + arch: NodeJS.Architecture = process.arch +): string { + const exeSuffix = platform === 'win32' ? '.exe' : ''; + return `dev-env-update-host-${ platform }-${ arch }${ exeSuffix }`; +} + +export async function installBinary( + version: string, + dest: string, + timeout = 0, + arch = process.arch, + platform = process.platform +): Promise< string > { + const [ binaryUrl, checksumUrl ] = getReleaseUrl( version, arch, platform ); + const checksum = ( await download( new URL( checksumUrl ), true, timeout ) ).trim(); + const compressedStream = await download( new URL( binaryUrl ), false, timeout ); + + if ( ! compressedStream ) { + throw new Error( 'Failed to download binary' ); + } + + const hash = createHash( 'sha256' ); + const hashTap = new Transform( { + transform( chunk: BinaryLike, _encoding, callback ) { + hash.update( chunk ); + callback( null, chunk ); + }, + } ); + + const destFilename = join( dest, getExeName( platform, arch ) ); + // Use a unique temp name to avoid collisions when multiple processes install concurrently. + const tempFilename = `${ destFilename }.${ randomBytes( 8 ).toString( 'hex' ) }.tmp`; + + const outStream = createWriteStream( tempFilename, { mode: 0o755 } ); + let removeTmp = true; + try { + await pipeline( compressedStream, hashTap, createGunzip(), outStream ); + + const calculatedChecksum = hash.digest( 'hex' ); + if ( + ! timingSafeEqual( Buffer.from( calculatedChecksum, 'hex' ), Buffer.from( checksum, 'hex' ) ) + ) { + throw new InvalidChecksumError( + format( + 'Downloaded file checksum does not match expected value (expected: %s, got: %s)', + checksum, + calculatedChecksum + ) + ); + } + + await rename( tempFilename, destFilename ); + removeTmp = false; + } finally { + if ( removeTmp ) { + await rm( tempFilename, { force: true } ).catch( err => { + console.warn( 'Error removing temporary file %s: %s', tempFilename, err ); + } ); + } + } + + return destFilename; +} + +export async function getInstallDir(): Promise< string > { + const binDir = join( dirname( __dirname ), 'bin' ); + try { + await mkdir( binDir, { recursive: true } ); + await access( binDir, constants.W_OK ); + return binDir; + } catch { + // Swallow errors and fall back to a temporary directory + } + + const tmpDir = await mkdtemp( join( tmpdir(), 'dev-env-update-hosts-' ) ); + process.once( 'exit', () => { + try { + rmSync( tmpDir, { recursive: true, force: true } ); + } catch ( err ) { + console.warn( 'Error removing temporary dir: %s', err ); + } + } ); + + return tmpDir; +} + +export function updateDomains( binary: string, domains: string[] ): Promise< void > { + return new Promise( ( resolve, reject ) => { + const child = spawn( binary, domains, { + stdio: 'inherit', + } ); + + child.on( 'error', err => reject( err ) ); + child.on( 'exit', code => { + if ( code === 0 ) { + resolve(); + } else { + reject( new Error( `Binary exited with code ${ code }` ) ); + } + } ); + } ); +}