diff --git a/vscode-dotnet-runtime-extension/src/extension.ts b/vscode-dotnet-runtime-extension/src/extension.ts index 6ec2062f84..dc0cc60ef4 100644 --- a/vscode-dotnet-runtime-extension/src/extension.ts +++ b/vscode-dotnet-runtime-extension/src/extension.ts @@ -266,7 +266,7 @@ export function activate(vsCodeContext: vscode.ExtensionContext, extensionContex commandContext.forceUpdate = true; } - const isOffline = !(await WebRequestWorkerSingleton.getInstance().isOnline(timeoutValue ?? defaultTimeoutValue, globalEventStream)); + const isOffline = !(await WebRequestWorkerSingleton.getInstance().isOnline(timeoutValue ?? defaultTimeoutValue, globalEventStream, workerContext.proxyUrl)); if (!commandContext.forceUpdate || isOffline) { // 3.0 Breaking Change: Don't always return latest .NET runtime by default @@ -971,7 +971,7 @@ ${JSON.stringify(commandContext)}`)); async function getExistingInstallIfOffline(worker: DotnetCoreAcquisitionWorker, workerContext: IAcquisitionWorkerContext): Promise { - const isOffline = !(await WebRequestWorkerSingleton.getInstance().isOnline(timeoutValue ?? defaultTimeoutValue, globalEventStream)); + const isOffline = !(await WebRequestWorkerSingleton.getInstance().isOnline(timeoutValue ?? defaultTimeoutValue, globalEventStream, workerContext.proxyUrl)); if (isOffline) { return getExistingInstallOffline(worker, workerContext); @@ -989,7 +989,7 @@ ${JSON.stringify(commandContext)}`)); } else { - if (!(await WebRequestWorkerSingleton.getInstance().isOnline(timeoutValue ?? defaultTimeoutValue, globalEventStream))) + if (!(await WebRequestWorkerSingleton.getInstance().isOnline(timeoutValue ?? defaultTimeoutValue, globalEventStream, workerContext.proxyUrl))) { globalEventStream.post(new DotnetOfflineWarning(`It looks like you may be offline (can you connect to www.microsoft.com?) and have no compatible installations of .NET ${workerContext.acquisitionContext.version} for ${workerContext.acquisitionContext.requestingExtensionId ?? 'user'}. Installation will timeout in ${timeoutValue} seconds.`)) diff --git a/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts b/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts index cd41a21ac6..ac5395b5dc 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts @@ -147,7 +147,7 @@ If you cannot change this flag, try setting a custom existingDotnetPath via the }).catch(psErr => reject(psErr)); return; } - if (!(await WebRequestWorkerSingleton.getInstance().isOnline(this.workerContext.timeoutSeconds, this.eventStream))) + if (!(await WebRequestWorkerSingleton.getInstance().isOnline(this.workerContext.timeoutSeconds, this.eventStream, this.workerContext.proxyUrl))) { const offlineError = new EventBasedError('DotnetOfflineFailure', 'No internet connection detected: Cannot install .NET'); this.eventStream.post(new DotnetOfflineFailure(offlineError, install)); diff --git a/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts b/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts index baaf5bc55f..ecce5fd580 100644 --- a/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts +++ b/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts @@ -158,7 +158,7 @@ export class WebRequestWorkerSingleton { timeoutCancelTokenHook.abort(); ctx.eventStream.post(new WebRequestTime(`Timer for request:`, String(this.timeoutMsFromCtx(ctx)), 'false', url, '777')); // 777 for custom abort status. arbitrary - if (!(await this.isOnline(ctx.timeoutSeconds, ctx.eventStream))) + if (!(await this.isOnline(ctx.timeoutSeconds, ctx.eventStream, ctx.proxyUrl))) { const offlineError = new EventBasedError('DotnetOfflineFailure', 'No internet connection detected: Cannot install .NET'); ctx.eventStream.post(new DotnetOfflineFailure(offlineError, null)); @@ -269,7 +269,7 @@ export class WebRequestWorkerSingleton } } - public async isOnline(timeoutSec: number, eventStream: IEventStream): Promise + public async isOnline(timeoutSec: number, eventStream: IEventStream, proxyUrl?: string): Promise { if (process.env.DOTNET_INSTALL_TOOL_OFFLINE === '1') { @@ -281,7 +281,7 @@ export class WebRequestWorkerSingleton // ... 100 ms is there as a default to prevent the dns resolver from throwing a runtime error if the user sets timeoutSeconds to 0. const dnsResolver = new dns.promises.Resolver({ timeout: expectedDNSResolutionTimeMs }); - const couldConnect = await dnsResolver.resolve(microsoftServerHostName).then(() => + const dnsOnline = await dnsResolver.resolve(microsoftServerHostName).then(() => { return true; }).catch((error: any) => @@ -290,7 +290,47 @@ export class WebRequestWorkerSingleton return false; }); - return couldConnect; + if (dnsOnline) + { + return true; + } + + // DNS failed — but in proxy environments, the proxy handles DNS resolution, so direct DNS lookups may fail + // even though HTTP connectivity works fine. Fall back to a lightweight HEAD request through the proxy-configured client. + if (this.client) + { + const httpFallbackTimeoutMs = Math.max(timeoutSec * 1000, 2000); + const proxyAgent = await this.getProxyAgent(proxyUrl, eventStream); + + const headOptions: object = { + timeout: httpFallbackTimeoutMs, + cache: false, + validateStatus: () => true, // Any HTTP response means we're online, even 4xx/5xx + ...(proxyAgent !== null && { proxy: false }), + ...(proxyAgent !== null && { httpsAgent: proxyAgent }), + }; + + const headOnline = await this.client.head(`https://${microsoftServerHostName}`, headOptions) + .then(() => + { + return true; // Any response at all means we have connectivity + }) + .catch(() => + { + return false; + }); + + if (headOnline) + { + eventStream.post(new OfflineDetectionLogicTriggered(new EventCancellationError('DnsFailedButHttpSucceeded', + `DNS resolution failed but HTTP HEAD request succeeded. This may indicate a proxy is handling DNS.`), + `DNS failed but HTTP connectivity confirmed via HEAD request to ${microsoftServerHostName}.`)); + } + + return headOnline; + } + + return false; } /** * @@ -321,11 +361,22 @@ export class WebRequestWorkerSingleton } private async GetProxyAgentIfNeeded(ctx: IAcquisitionWorkerContext): Promise | null> + { + return this.getProxyAgent(ctx.proxyUrl, ctx.eventStream); + } + + /** + * Resolves a proxy agent from the manual proxy URL or auto-detected system proxy settings. + * Decoupled from IAcquisitionWorkerContext so it can be used by isOnline and other callers that don't have a full context. + */ + private async getProxyAgent(manualProxyUrl?: string, eventStream?: IEventStream): Promise | null> { try { + const hasManualProxy = this.proxySettingConfiguredManually(manualProxyUrl); + let discoveredProxy = ''; - if (!this.proxySettingConfiguredManually(ctx)) + if (!hasManualProxy) { const autoDetectProxies = await getProxySettings(); if (autoDetectProxies?.https) @@ -338,17 +389,17 @@ export class WebRequestWorkerSingleton } } - if (this.proxySettingConfiguredManually(ctx) || discoveredProxy) + if (hasManualProxy || discoveredProxy) { - const finalProxy = ctx?.proxyUrl && ctx?.proxyUrl !== '""' && ctx?.proxyUrl !== '' ? ctx.proxyUrl : discoveredProxy; - ctx.eventStream.post(new ProxyUsed(`Utilizing the Proxy : Manual ? ${ctx?.proxyUrl}, Automatic: ${discoveredProxy}, Decision : ${finalProxy}`)) + const finalProxy = hasManualProxy ? manualProxyUrl! : discoveredProxy; + eventStream?.post(new ProxyUsed(`Utilizing the Proxy : Manual ? ${manualProxyUrl}, Automatic: ${discoveredProxy}, Decision : ${finalProxy}`)) const proxyAgent = new HttpsProxyAgent(finalProxy); return proxyAgent; } } catch (error: any) { - ctx.eventStream.post(new SuppressedAcquisitionError(error, `The proxy lookup failed, most likely due to limited registry access. Skipping automatic proxy lookup.`)); + eventStream?.post(new SuppressedAcquisitionError(error, `The proxy lookup failed, most likely due to limited registry access. Skipping automatic proxy lookup.`)); } return null; @@ -485,9 +536,9 @@ If you're on a proxy and disable registry access, you must set the proxy in our } } - private proxySettingConfiguredManually(ctx: IAcquisitionWorkerContext): boolean + private proxySettingConfiguredManually(proxyUrl?: string): boolean { - return ctx?.proxyUrl ? ctx?.proxyUrl !== '""' : false; + return proxyUrl ? proxyUrl !== '""' && proxyUrl !== '' : false; } private timeoutMsFromCtx(ctx: IAcquisitionWorkerContext): number diff --git a/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts b/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts index 490f606941..293c14f408 100644 --- a/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts +++ b/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts @@ -5,6 +5,9 @@ import * as chai from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; +import * as http from 'http'; +import * as https from 'https'; +import * as dns from 'dns'; import * as path from 'path'; import { DotnetCoreAcquisitionWorker } from '../../Acquisition/DotnetCoreAcquisitionWorker'; import { IInstallScriptAcquisitionWorker } from '../../Acquisition/IInstallScriptAcquisitionWorker'; @@ -12,6 +15,7 @@ import { DotnetFallbackInstallScriptUsed, DotnetInstallScriptAcquisitionError, + OfflineDetectionLogicTriggered, WebRequestTime, } from '../../EventStream/EventStreamEvents'; import @@ -137,3 +141,271 @@ suite('WebRequestWorker Unit Tests', function () }); }); +/** + * Helper that intercepts all outbound HTTP/HTTPS requests and DNS lookups to simulate a fully offline machine. + * Error codes match real observed behavior when a firewall blocks node.exe outbound traffic: + * DNS: ETIMEOUT, Axios: EACCES with no response. + * Returns a restore function that undoes the patching. + */ +function simulateOffline(): () => void +{ + const originalHttpsRequest = https.request; + const originalHttpRequest = http.request; + const originalDnsResolve = dns.promises.Resolver.prototype.resolve; + + // Block all HTTPS requests — emit EACCES matching real firewall behavior + (https as any).request = function (...args: any[]) + { + const req = new http.ClientRequest('https://localhost:1'); + const err: NodeJS.ErrnoException = new Error('connect EACCES'); + err.code = 'EACCES'; + err.errno = -4092; + err.syscall = 'connect'; + process.nextTick(() => req.destroy(err)); + return req; + }; + + // Block all HTTP requests + (http as any).request = function (...args: any[]) + { + const req = new http.ClientRequest('http://localhost:1'); + const err: NodeJS.ErrnoException = new Error('connect EACCES'); + err.code = 'EACCES'; + err.errno = -4092; + err.syscall = 'connect'; + process.nextTick(() => req.destroy(err)); + return req; + }; + + // Block DNS resolution — emit ETIMEOUT matching real offline behavior + dns.promises.Resolver.prototype.resolve = function () + { + const err: NodeJS.ErrnoException = new Error('queryA ETIMEOUT www.microsoft.com'); + err.code = 'ETIMEOUT'; + return Promise.reject(err); + } as any; + + return () => + { + (https as any).request = originalHttpsRequest; + (http as any).request = originalHttpRequest; + dns.promises.Resolver.prototype.resolve = originalDnsResolve; + }; +} + +/** + * Helper that blocks only DNS resolution but allows TCP/TLS connections through. + * Simulates a proxy environment where DNS doesn't resolve locally but HTTP works. + */ +function simulateDnsOnlyFailure(): () => void +{ + const originalDnsResolve = dns.promises.Resolver.prototype.resolve; + + dns.promises.Resolver.prototype.resolve = function () + { + const err: NodeJS.ErrnoException = new Error('queryA ETIMEOUT www.microsoft.com'); + err.code = 'ETIMEOUT'; + return Promise.reject(err); + } as any; + + return () => + { + dns.promises.Resolver.prototype.resolve = originalDnsResolve; + }; +} + +suite('isOnline Connectivity Detection Tests', function () +{ + this.afterEach(async () => + { + // Reset the singleton so each test gets a fresh instance + (WebRequestWorkerSingleton as any).instance = undefined; + }); + + test('isOnline returns false when fully offline (DNS + HTTP both blocked)', async () => + { + const eventStream = new MockEventStream(); + + // Reset singleton so the new instance is created while network is blocked + (WebRequestWorkerSingleton as any).instance = undefined; + + const restoreNetwork = simulateOffline(); + try + { + const result = await WebRequestWorkerSingleton.getInstance().isOnline(5, eventStream); + assert.isFalse(result, 'Should report offline when all network is blocked'); + + const offlineEvent = eventStream.events.find(event => event instanceof OfflineDetectionLogicTriggered); + assert.exists(offlineEvent, 'Should log an offline detection event for the DNS failure'); + } + finally + { + restoreNetwork(); + } + }).timeout(15000); + + test('isOnline returns true when DNS fails but HTTP succeeds (proxy environment)', async () => + { + const eventStream = new MockEventStream(); + const restoreNetwork = simulateDnsOnlyFailure(); + try + { + const result = await WebRequestWorkerSingleton.getInstance().isOnline(5, eventStream); + assert.isTrue(result, 'Should report online when DNS fails but HTTP HEAD succeeds'); + + const dnsFailEvent = eventStream.events.find(event => + event instanceof OfflineDetectionLogicTriggered && + event.supplementalMessage.includes('DNS resolution failed')); + assert.exists(dnsFailEvent, 'Should log a DNS failure event'); + + const httpSuccessEvent = eventStream.events.find(event => + event instanceof OfflineDetectionLogicTriggered && + event.supplementalMessage.includes('HTTP connectivity confirmed')); + assert.exists(httpSuccessEvent, 'Should log that HTTP fallback succeeded'); + } + finally + { + restoreNetwork(); + } + }).timeout(15000); + + test('isOnline returns true when DNS succeeds (normal environment)', async () => + { + const eventStream = new MockEventStream(); + const result = await WebRequestWorkerSingleton.getInstance().isOnline(5, eventStream); + assert.isTrue(result, 'Should report online when DNS resolves successfully'); + }).timeout(15000); + + test('isOnline returns false when DOTNET_INSTALL_TOOL_OFFLINE env var is set', async () => + { + const eventStream = new MockEventStream(); + const originalEnv = process.env.DOTNET_INSTALL_TOOL_OFFLINE; + process.env.DOTNET_INSTALL_TOOL_OFFLINE = '1'; + try + { + const result = await WebRequestWorkerSingleton.getInstance().isOnline(5, eventStream); + assert.isFalse(result, 'Should report offline when DOTNET_INSTALL_TOOL_OFFLINE=1'); + } + finally + { + if (originalEnv === undefined) + { + delete process.env.DOTNET_INSTALL_TOOL_OFFLINE; + } + else + { + process.env.DOTNET_INSTALL_TOOL_OFFLINE = originalEnv; + } + } + }).timeout(5000); +}); + +import * as net from 'net'; + +/** + * Starts a local HTTP CONNECT proxy server on a random port. + * This proxy handles HTTPS tunneling (CONNECT method) by piping a raw TCP connection + * between the client and the target host. The proxy itself speaks plain HTTP — the client + * sends "CONNECT host:443 HTTP/1.1", the proxy opens a TCP socket to host:443, then pipes + * bytes bidirectionally. TLS is negotiated end-to-end between the client and target server + * (the proxy never sees decrypted traffic). + * + * @returns { server, port, close } — the server, its port, and a cleanup function. + */ +async function startLocalProxy(): Promise<{ server: http.Server; port: number; close: () => Promise }> +{ + const server = http.createServer((_req, res) => + { + // Reject non-CONNECT requests (we only support tunneling) + res.writeHead(405); + res.end('Only CONNECT is supported'); + }); + + server.on('connect', (req: http.IncomingMessage, clientSocket: net.Socket, head: Buffer) => + { + const [host, portStr] = (req.url ?? '').split(':'); + const port = parseInt(portStr, 10) || 443; + + const targetSocket = net.connect(port, host, () => + { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + targetSocket.write(head); + targetSocket.pipe(clientSocket); + clientSocket.pipe(targetSocket); + }); + + targetSocket.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => targetSocket.destroy()); + }); + + return new Promise((resolve) => + { + server.listen(0, '127.0.0.1', () => + { + const addr = server.address() as net.AddressInfo; + resolve({ + server, + port: addr.port, + close: () => new Promise((res) => server.close(() => res())) + }); + }); + }); +} + +suite('Proxy-based Connectivity Tests', function () +{ + let proxy: { server: http.Server; port: number; close: () => Promise }; + let restoreDns: (() => void) | null = null; + + this.beforeEach(async () => + { + proxy = await startLocalProxy(); + }); + + this.afterEach(async () => + { + if (restoreDns) + { + restoreDns(); + restoreDns = null; + } + (WebRequestWorkerSingleton as any).instance = undefined; + await proxy.close(); + }); + + test('isOnline returns true via proxy when DNS is blocked', async () => + { + const eventStream = new MockEventStream(); + const proxyUrl = `http://127.0.0.1:${proxy.port}`; + + // Block DNS — simulates enterprise environment where client can't resolve external DNS + restoreDns = simulateDnsOnlyFailure(); + + const result = await WebRequestWorkerSingleton.getInstance().isOnline(10, eventStream, proxyUrl); + assert.isTrue(result, 'Should report online when proxy handles the connection despite DNS failure'); + + const httpSuccess = eventStream.events.find(event => + event instanceof OfflineDetectionLogicTriggered && + event.supplementalMessage.includes('HTTP connectivity confirmed')); + assert.exists(httpSuccess, 'Should log that the HTTP fallback via proxy succeeded'); + }).timeout(15000); + + test('Web request succeeds through proxy when DNS is blocked', async () => + { + const eventStream = new MockEventStream(); + const proxyUrl = `http://127.0.0.1:${proxy.port}`; + const ctx = getMockAcquisitionContext('runtime', '', 30, eventStream); + ctx.proxyUrl = proxyUrl; + + // Block DNS + restoreDns = simulateDnsOnlyFailure(); + + // Make a real web request through the proxy — this exercises the full getAxiosOptions → GetProxyAgentIfNeeded → HttpsProxyAgent chain + const result = await WebRequestWorkerSingleton.getInstance().getCachedData(staticWebsiteUrl, ctx); + assert.exists(result, 'Should receive data through the proxy even with DNS blocked'); + // The response is a JSON object (parsed by axios). Verify it contains expected structure. + const resultStr = typeof result === 'string' ? result : JSON.stringify(result); + assert.isTrue(resultStr.length > 0, 'Response should contain data'); + assert.include(resultStr, 'channel-version', 'Response should contain expected release metadata'); + }).timeout(30000); +}); diff --git a/vscode-dotnet-runtime-library/yarn.lock b/vscode-dotnet-runtime-library/yarn.lock index 0000380ae7..c5cb0f7b6c 100644 --- a/vscode-dotnet-runtime-library/yarn.lock +++ b/vscode-dotnet-runtime-library/yarn.lock @@ -644,6 +644,11 @@ fs.realpath@^1.0.0: resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@^2.3.3: + version "2.3.3" + resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/fsevents/-/fsevents-2.3.3.tgz" + integrity sha1-ysZAd4XQNnWipeGlMFxpezR9kNY= + function-bind@^1.1.2: version "1.1.2" resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/function-bind/-/function-bind-1.1.2.tgz"