diff --git a/fluxer_app/src/features/gateway/transport/GatewayWireTransport.test.ts b/fluxer_app/src/features/gateway/transport/GatewayWireTransport.test.ts index 6dfa374b1..90abf7541 100644 --- a/fluxer_app/src/features/gateway/transport/GatewayWireTransport.test.ts +++ b/fluxer_app/src/features/gateway/transport/GatewayWireTransport.test.ts @@ -9,9 +9,10 @@ import { isDesktopNativeGatewayTransportAvailable, } from '@app/features/gateway/transport/GatewayWireTransport'; import {isDesktopLocalAppDocument} from '@app/features/platform/DesktopLocalAppRuntime'; -import type { - NativeGatewayTransportAPI, - NativeGatewayTransportEvent, +import { + NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE, + type NativeGatewayTransportAPI, + type NativeGatewayTransportEvent, } from '@fluxer/desktop_ipc/src/GatewayTransportContract'; import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; @@ -431,7 +432,7 @@ describe('native transport fallback', () => { test('native creates the shell refuses count toward the fallback', async () => { const stub = createNativeGatewayTransportStub({ create: async () => { - throw new Error('The native gateway transport cannot reach a gateway that resolves through a proxy'); + throw new Error('The native gateway transport addon is unavailable'); }, }); installElectronAPI(stub.api); @@ -452,6 +453,28 @@ describe('native transport fallback', () => { ); }); + test('a gateway the shell routes through a proxy switches new transports to the browser at once', async () => { + const stub = createNativeGatewayTransportStub({ + create: async () => { + throw new Error( + `Error invoking remote method 'native-gateway-transport:create': Error: ${NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE}`, + ); + }, + }); + installElectronAPI(stub.api); + + const transport = createGatewayWireTransport('wss://gateway.example/', {binaryType: 'arraybuffer'}); + expect(transport.kind).toBe(GatewayWireTransportKind.DESKTOP_NATIVE); + transport.on('error', () => undefined); + transport.start(); + await flush(); + + const next = createGatewayWireTransport('wss://gateway.example/', {binaryType: 'arraybuffer'}); + expect(next.kind).toBe(GatewayWireTransportKind.BROWSER); + next.start(); + expect(FakeWebSocket.instances.map((socket) => socket.url)).toEqual(['wss://gateway.example/']); + }); + test('an open between failures resets the count', async () => { const stub = createNativeGatewayTransportStub(); installElectronAPI(stub.api); diff --git a/fluxer_app/src/features/gateway/transport/GatewayWireTransport.ts b/fluxer_app/src/features/gateway/transport/GatewayWireTransport.ts index 7206ac26b..1e14975d5 100644 --- a/fluxer_app/src/features/gateway/transport/GatewayWireTransport.ts +++ b/fluxer_app/src/features/gateway/transport/GatewayWireTransport.ts @@ -6,6 +6,7 @@ import {Logger} from '@app/features/platform/utils/AppLogger'; import {randomUuid} from '@app/features/platform/utils/RandomUuid'; import {getElectronAPI} from '@app/features/ui/utils/NativeUtils'; import { + NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE, type NativeGatewayTransportAPI, type NativeGatewayTransportCloseRequest, type NativeGatewayTransportEvent, @@ -433,7 +434,11 @@ class DesktopNativeGatewayWireTransport extends BaseGatewayWireTransport { private failOpen(error: unknown): void { if (this.disposed) return; - recordNativeGatewayFailureWithoutOpen(); + if (describeError(error).includes(NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE)) { + switchProxiedGatewayToBrowserTransport(); + } else { + recordNativeGatewayFailureWithoutOpen(); + } this.abandonNativeConnection(); this.emitTransportFailure(error); } @@ -600,6 +605,13 @@ function recordNativeGatewayFailureWithoutOpen(): void { } } +function switchProxiedGatewayToBrowserTransport(): void { + const state = getRouterState(); + if (isNativeGatewayFallbackActive(state)) return; + state.nativeFailuresWithoutOpen = NATIVE_GATEWAY_FAILURES_BEFORE_BROWSER_FALLBACK; + log.warn('Desktop native gateway transport cannot use the configured proxy, using the browser transport'); +} + function createDesktopConnectionId(): string { return `${DESKTOP_CONNECTION_ID_PREFIX}${getRouterState().rendererRouterId}:${randomUuid()}`; } diff --git a/fluxer_desktop/src/main/AppImageUpdate.test.mjs b/fluxer_desktop/src/main/AppImageUpdate.test.mjs index f58d49a6b..cfcc51112 100644 --- a/fluxer_desktop/src/main/AppImageUpdate.test.mjs +++ b/fluxer_desktop/src/main/AppImageUpdate.test.mjs @@ -223,6 +223,7 @@ describe('AppImageUpdate in-place replacement', () => { const progress = []; const staged = await appImageUpdate.stageAppImageUpdate({ + fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, @@ -249,6 +250,7 @@ describe('AppImageUpdate in-place replacement', () => { const resolved = appImageUpdate.resolveAppImageTarget(); const staged = await appImageUpdate.stageAppImageUpdate({ + fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, @@ -266,6 +268,7 @@ describe('AppImageUpdate in-place replacement', () => { await assert.rejects( appImageUpdate.stageAppImageUpdate({ + fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/corrupt`, expectedSha256: NEW_SHA256, @@ -289,6 +292,7 @@ describe('AppImageUpdate in-place replacement', () => { await assert.rejects( appImageUpdate.stageAppImageUpdate({ + fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: '', @@ -307,6 +311,7 @@ describe('AppImageUpdate in-place replacement', () => { await assert.rejects( appImageUpdate.stageAppImageUpdate({ + fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/nothing-here`, expectedSha256: NEW_SHA256, @@ -346,6 +351,7 @@ describe('AppImageUpdate in-place replacement', () => { const resolved = appImageUpdate.resolveAppImageTarget(); const staged = await appImageUpdate.stageAppImageUpdate({ + fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, @@ -387,6 +393,7 @@ describe('AppImageUpdate in-place replacement', () => { await assert.rejects( appImageUpdate.stageAppImageUpdate({ + fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, @@ -428,6 +435,7 @@ describe('AppImageUpdate in-place replacement', () => { const resolved = appImageUpdate.resolveAppImageTarget(); const staged = await appImageUpdate.stageAppImageUpdate({ + fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, @@ -465,6 +473,7 @@ describe('AppImageUpdate in-place replacement', () => { await assert.rejects( appImageUpdate.stageAppImageUpdate({ + fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, @@ -597,6 +606,7 @@ describe('AppImageUpdate in-place replacement', () => { const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); const staged = await appImageUpdate.stageAppImageUpdate({ + fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, @@ -619,6 +629,7 @@ describe('AppImageUpdate abandoned staging cleanup', () => { const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const resolved = appImageUpdate.resolveAppImageTarget(); const abandoned = await appImageUpdate.stageAppImageUpdate({ + fetchImpl: fetch, target: resolved.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, @@ -641,6 +652,7 @@ describe('AppImageUpdate abandoned staging cleanup', () => { const resolved = appImageUpdate.resolveAppImageTarget(); const sibling = appImageUpdate.resolveAppImageTarget(siblingPath); const siblingStaged = await appImageUpdate.stageAppImageUpdate({ + fetchImpl: fetch, target: sibling.target, url: `${baseUrl}/appimage`, expectedSha256: NEW_SHA256, diff --git a/fluxer_desktop/src/main/AppImageUpdate.ts b/fluxer_desktop/src/main/AppImageUpdate.ts index c181cf8f5..2a51ce3a3 100644 --- a/fluxer_desktop/src/main/AppImageUpdate.ts +++ b/fluxer_desktop/src/main/AppImageUpdate.ts @@ -166,14 +166,13 @@ export async function stageAppImageUpdate(options: { url: string; expectedSha256: string; onProgress?: (progress: AppImageDownloadProgress) => void; - fetchImpl?: typeof fetch; + fetchImpl: typeof fetch; }): Promise { const expected = options.expectedSha256.trim().toLowerCase(); if (!SHA256_PATTERN.test(expected)) { throw new Error('Update checksum is missing or malformed.'); } - const fetchImpl = options.fetchImpl ?? fetch; - const response = await fetchImpl(options.url, {cache: 'no-store', redirect: 'follow'}); + const response = await options.fetchImpl(options.url, {cache: 'no-store', redirect: 'follow'}); if (!response.ok || response.body == null) { throw new Error(`Update download failed: ${response.status}`); } diff --git a/fluxer_desktop/src/main/DesktopOutboundHTTP.test.mjs b/fluxer_desktop/src/main/DesktopOutboundHTTP.test.mjs index 304fe511d..faca6eef2 100644 --- a/fluxer_desktop/src/main/DesktopOutboundHTTP.test.mjs +++ b/fluxer_desktop/src/main/DesktopOutboundHTTP.test.mjs @@ -726,6 +726,116 @@ describe('DesktopSelectedInstanceClient', () => { }); }); +describe('DesktopOutboundHTTP system proxy routes', () => { + function proxiedOutboundHTTP() { + const sent = []; + const resolved = []; + const outboundHTTP = new DesktopOutboundHTTP({ + resolveHostAddresses: async (hostname) => { + resolved.push(hostname); + throw Object.assign(new Error(`getaddrinfo ENOTFOUND ${hostname}`), {syscall: 'getaddrinfo'}); + }, + resolveProxy: async (url) => + url.startsWith('https://direct.example') ? 'DIRECT' : 'PROXY 127.0.0.1:7890; DIRECT', + sendThroughSession: async (request) => { + sent.push({method: request.method, url: request.url.href, headers: request.headers}); + return { + headers: {'content-type': 'image/png'}, + message: Readable.from([PNG_BYTES]), + status: 200, + statusText: 'OK', + }; + }, + }); + return {outboundHTTP, resolved, sent}; + } + + test('an instance behind the system proxy is anchored and fetched without a local lookup', async () => { + const {outboundHTTP, resolved, sent} = proxiedOutboundHTTP(); + const requirement = await outboundHTTP.registerAnchoredOrigins({ + anchorOrigin: 'https://api.blocked.example', + origins: ['https://media.blocked.example'], + }); + assert.equal(requirement, 'public'); + const message = await outboundHTTP.request({ + body: null, + expectedOrigin: 'https://media.blocked.example', + headers: {Accept: 'image/png'}, + method: 'GET', + originTrust: DesktopOriginTrust.REGISTERED, + serviceName: 'test', + signal: null, + timeoutMs: 500, + url: 'https://media.blocked.example/avatar.png', + }); + assert.deepEqual( + await readBoundedMessage({ + declaredBytes: null, + description: 'proxied', + maxBytes: 64, + maxChunks: 4, + message: message.message, + }), + PNG_BYTES, + ); + assert.deepEqual(resolved, []); + assert.deepEqual(sent, [ + {method: 'GET', url: 'https://media.blocked.example/avatar.png', headers: {Accept: 'image/png'}}, + ]); + outboundHTTP.cleanup(); + }); + + test('a proxied download goes through the session and a direct one keeps the pinned lookup', async () => { + const {outboundHTTP, resolved, sent} = proxiedOutboundHTTP(); + const message = await outboundHTTP.get({ + context: 'test', + timeoutMs: 500, + url: new URL('https://cdn.blocked.example/icon.png'), + }); + message.message.resume(); + await assert.rejects( + outboundHTTP.get({context: 'test', timeoutMs: 500, url: new URL('https://direct.example/icon.png')}), + /could not be reached/, + ); + assert.deepEqual( + sent.map((entry) => entry.url), + ['https://cdn.blocked.example/icon.png'], + ); + assert.deepEqual(resolved, ['direct.example']); + outboundHTTP.cleanup(); + }); + + test('a proxied route keeps the cleartext and private address rules', async () => { + const {outboundHTTP, sent} = proxiedOutboundHTTP(); + await outboundHTTP.registerAnchoredOrigins({ + anchorOrigin: 'https://api.blocked.example', + origins: ['http://10.0.0.1'], + }); + const request = (url, originTrust = DesktopOriginTrust.BOUND) => ({ + body: null, + expectedOrigin: new URL(url).origin, + headers: null, + method: 'GET', + originTrust, + serviceName: 'test', + signal: null, + timeoutMs: 500, + url, + }); + await assert.rejects(outboundHTTP.request(request('http://plain.blocked.example/x')), /requires https/); + await assert.rejects( + outboundHTTP.request(request('http://10.0.0.1/x', DesktopOriginTrust.REGISTERED)), + /cannot authorize the privately resolving origin/, + ); + await assert.rejects( + outboundHTTP.get({context: 'test', timeoutMs: 500, url: new URL('https://10.0.0.1/x')}), + /could not be reached/, + ); + assert.deepEqual(sent, []); + outboundHTTP.cleanup(); + }); +}); + describe('desktop instance launch surfaces', () => { test('--fluxer-instance is a development-only API endpoint flag', () => { const originalDefaultApp = process.defaultApp; diff --git a/fluxer_desktop/src/main/DesktopOutboundHTTP.ts b/fluxer_desktop/src/main/DesktopOutboundHTTP.ts index 8b2c9b650..383d69f44 100644 --- a/fluxer_desktop/src/main/DesktopOutboundHTTP.ts +++ b/fluxer_desktop/src/main/DesktopOutboundHTTP.ts @@ -8,6 +8,13 @@ import {isIPv4, isIPv6, type LookupFunction} from 'node:net'; import {Readable, Transform} from 'node:stream'; import type {ReadableStream as NodeReadableStream} from 'node:stream/web'; import {createChildLogger} from '@electron/common/Logger'; +import { + type DesktopProxyResolver, + type DesktopSessionHTTPSender, + isDirectProxyRoute, + resolveDesktopSessionProxy, + sendThroughDesktopSession, +} from '@electron/main/DesktopSessionHTTP'; import {normalizeHTTPNetworkOrigin} from '@fluxer/instance_bootstrap/src/NetworkOrigin'; const logger = createChildLogger('DesktopOutboundHTTP'); @@ -120,6 +127,8 @@ interface DesktopAnchoredOriginRegistration { interface DesktopOutboundHTTPOptions { readonly resolveHostAddresses?: DesktopHostAddressResolver; + readonly resolveProxy?: DesktopProxyResolver; + readonly sendThroughSession?: DesktopSessionHTTPSender; } interface DesktopOutboundGETRequest { @@ -130,7 +139,7 @@ interface DesktopOutboundGETRequest { export interface DesktopOutboundHTTPMessage { readonly headers: http.IncomingHttpHeaders; - readonly message: http.IncomingMessage; + readonly message: Readable; readonly status: number; readonly statusText: string; readonly url: URL; @@ -432,6 +441,11 @@ function isPublicPinnedAddress(pinned: PinnedAddress): boolean { return pinned.family === 4 ? isPublicIPv4Address(pinned.address) : isPublicIPv6Address(pinned.address); } +function proxiedHostScope(hostname: string): DesktopOriginAddressScope { + const literal = parseIPAddress(hostname); + return literal == null ? DesktopOriginAddressScope.PUBLIC : addressScope(literal); +} + export function requireDesktopHTTPOrigin(value: string): string { const normalized = normalizeHTTPNetworkOrigin(value); if (normalized == null) { @@ -687,6 +701,17 @@ function createRequestBodyLimit(maximumBytes: number): Transform { }); } +interface DesktopProxiedSend { + readonly body: Uint8Array | Readable | null; + readonly headers: Readonly>; + readonly method: string; + readonly release: () => void; + readonly signal: AbortSignal | null; + readonly target: URL; + readonly timeout: () => Error; + readonly timeoutMs: number; +} + export class DesktopOutboundHTTP { private readonly httpAgent = new http.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS}); private readonly httpsAgent = new https.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS}); @@ -703,6 +728,8 @@ export class DesktopOutboundHTTP { private readonly registeredRequirements = new Map(); private readonly originRequestsInFlightByService = new Map(); private readonly resolveHostAddresses: DesktopHostAddressResolver; + private readonly resolveProxy: DesktopProxyResolver; + private readonly sendThroughSession: DesktopSessionHTTPSender; private inFlight = 0; private originRequestsInFlight = 0; private activeResolutions = 0; @@ -710,6 +737,8 @@ export class DesktopOutboundHTTP { public constructor(options: DesktopOutboundHTTPOptions = {}) { this.resolveHostAddresses = options.resolveHostAddresses ?? lookupAllAddresses; + this.resolveProxy = options.resolveProxy ?? resolveDesktopSessionProxy; + this.sendThroughSession = options.sendThroughSession ?? sendThroughDesktopSession; } public async get(request: DesktopOutboundGETRequest): Promise { @@ -730,6 +759,15 @@ export class DesktopOutboundHTTP { if (requirement === DesktopAddressRequirement.PUBLIC && request.url.protocol !== 'https:') { throw blocked(DesktopOutboundBlockReason.INSECURE_TRANSPORT, request.context, request.url.hostname); } + if (!(await this.routesDirect(request.url))) { + if ( + requirement === DesktopAddressRequirement.PUBLIC && + proxiedHostScope(request.url.hostname) !== DesktopOriginAddressScope.PUBLIC + ) { + throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, request.context, request.url.hostname); + } + return await this.issueProxied(request, release); + } const pinned = await this.pinAddress(request.url.hostname, requirement, request.context); return await this.issue(request, pinned, release); } catch (error) { @@ -779,6 +817,11 @@ export class DesktopOutboundHTTP { canonicalAnchor: string, unresolvedAnchorRequirement: DesktopAddressRequirement | null, ): Promise { + if (!(await this.routesDirect(new URL(canonicalAnchor)))) { + return proxiedHostScope(new URL(canonicalAnchor).hostname) === DesktopOriginAddressScope.PUBLIC + ? DesktopAddressRequirement.PUBLIC + : DesktopAddressRequirement.ANY; + } let anchor: DesktopOriginAddressBinding; try { anchor = await this.ensureBinding(canonicalAnchor, DesktopAddressRequirement.ANY); @@ -825,6 +868,17 @@ export class DesktopOutboundHTTP { throw new DesktopOutboundHTTPRequestBodyLimitError(maximumRequestBodyBytes); } const requirement = this.requirementFor(origin, request.originTrust); + if (!(await this.routesDirect(target))) { + const scope = proxiedHostScope(target.hostname); + if (requirement === DesktopAddressRequirement.PUBLIC && scope !== DesktopOriginAddressScope.PUBLIC) { + throw new DesktopOutboundHTTPPublicAddressRequiredError(origin); + } + if (scope === DesktopOriginAddressScope.PUBLIC && target.protocol !== 'https:') { + throw new DesktopOutboundHTTPInsecureTransportError(origin); + } + this.requireAdmission(); + return await this.issueProxiedOriginRequest(request, target); + } const binding = await this.ensureBinding(origin, requirement); if (binding.scope === DesktopOriginAddressScope.PUBLIC && target.protocol !== 'https:') { throw new DesktopOutboundHTTPInsecureTransportError(origin); @@ -860,6 +914,107 @@ export class DesktopOutboundHTTP { this.originRequestsInFlightByService.clear(); } + private async routesDirect(url: URL): Promise { + return isDirectProxyRoute(await this.resolveProxy(url.href)); + } + + private issueProxied(request: DesktopOutboundGETRequest, release: () => void): Promise { + return this.sendProxied({ + body: null, + headers: {}, + method: 'GET', + signal: null, + target: request.url, + timeout: () => new DesktopOutboundHTTPTimeoutError(), + timeoutMs: request.timeoutMs, + release, + }).catch((error: unknown) => { + if (error instanceof DesktopOutboundHTTPTimeoutError) { + throw error; + } + logger.warn('Outbound request failed', {context: request.context, hostname: request.url.hostname, error}); + throw new DesktopOutboundHTTPTransportError(); + }); + } + + private issueProxiedOriginRequest( + request: DesktopOutboundHTTPRequest, + target: URL, + ): Promise { + this.acquireSlot(request.serviceName); + let released = false; + const release = (): void => { + if (released) { + return; + } + released = true; + this.releaseSlot(request.serviceName); + }; + return this.sendProxied({ + body: this.proxiedRequestBody(request), + headers: requestHeaders(request.headers), + method: request.method, + signal: request.signal, + target, + timeout: () => new DesktopOutboundHTTPRequestTimeoutError(target.toString(), request.timeoutMs), + timeoutMs: request.timeoutMs, + release, + }).catch((error: unknown) => { + if (request.signal?.aborted === true) { + throw new DesktopOutboundHTTPRequestAbortedError(target.toString()); + } + throw error; + }); + } + + private async sendProxied({ + body, + headers, + method, + signal, + target, + timeout, + timeoutMs, + release, + }: DesktopProxiedSend): Promise { + const deadline = new AbortController(); + const timer = setTimeout(() => deadline.abort(timeout()), timeoutMs); + timer.unref(); + const settle = (): void => { + clearTimeout(timer); + release(); + }; + try { + const response = await this.sendThroughSession({ + body, + headers, + method, + signal: signal == null ? deadline.signal : AbortSignal.any([deadline.signal, signal]), + url: target, + }); + response.message.on('end', settle); + response.message.on('close', settle); + response.message.on('error', settle); + return {...response, url: target}; + } catch (error) { + settle(); + throw error; + } + } + + private proxiedRequestBody(request: DesktopOutboundHTTPRequest): Uint8Array | Readable | null { + const body = request.body; + if (body == null || body instanceof Uint8Array) { + return body; + } + const limit = createRequestBodyLimit( + request.maximumRequestBodyBytes ?? DESKTOP_OUTBOUND_HTTP_MAX_REQUEST_BODY_BYTES, + ); + const source = Readable.fromWeb(body as unknown as NodeReadableStream); + source.on('error', (error: Error) => limit.destroy(error)); + return source.pipe(limit); + } + private async pinAddress( hostname: string, requirement: DesktopAddressRequirement, diff --git a/fluxer_desktop/src/main/DesktopSessionHTTP.ts b/fluxer_desktop/src/main/DesktopSessionHTTP.ts new file mode 100644 index 000000000..6ab6e51cb --- /dev/null +++ b/fluxer_desktop/src/main/DesktopSessionHTTP.ts @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Buffer} from 'node:buffer'; +import type http from 'node:http'; +import {Readable, type Writable} from 'node:stream'; +import {net, session} from 'electron'; + +const CHROMIUM_REFUSED_REQUEST_HEADERS: ReadonlySet = new Set([ + 'connection', + 'content-length', + 'cookie2', + 'expect', + 'host', + 'keep-alive', + 'referer', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +const DIRECT_PROXY_ROUTE = 'DIRECT'; + +export interface DesktopSessionHTTPRequest { + readonly body: Uint8Array | Readable | null; + readonly headers: Readonly>; + readonly method: string; + readonly signal: AbortSignal; + readonly url: URL; +} + +export interface DesktopSessionHTTPResponse { + readonly headers: http.IncomingHttpHeaders; + readonly message: Readable; + readonly status: number; + readonly statusText: string; +} + +export type DesktopSessionHTTPSender = (request: DesktopSessionHTTPRequest) => Promise; + +export type DesktopProxyResolver = (url: string) => Promise; + +class DesktopSessionHTTPAbortedError extends Error { + public constructor(url: string) { + super(`Desktop session request to ${url} was aborted`); + this.name = 'DesktopSessionHTTPAbortedError'; + } +} + +export function isDirectProxyRoute(route: string): boolean { + const first = route.split(';', 1)[0] ?? ''; + return first.trim().toUpperCase() === DIRECT_PROXY_ROUTE; +} + +export function resolveDesktopSessionProxy(url: string): Promise { + return session.defaultSession.resolveProxy(url); +} + +function isRefusedRequestHeader(name: string): boolean { + const normalized = name.toLowerCase(); + return CHROMIUM_REFUSED_REQUEST_HEADERS.has(normalized) || normalized.startsWith('proxy-'); +} + +function decodedResponseHeaders(headers: Record>): http.IncomingHttpHeaders { + const result: http.IncomingHttpHeaders = {}; + const decoded = headers['content-encoding'] != null; + for (const [name, value] of Object.entries(headers)) { + const normalized = name.toLowerCase(); + if (decoded && (normalized === 'content-encoding' || normalized === 'content-length')) { + continue; + } + if (normalized === 'set-cookie') { + result['set-cookie'] = Array.isArray(value) ? value : [value]; + continue; + } + result[normalized] = Array.isArray(value) ? value.join(', ') : value; + } + return result; +} + +export function sendThroughDesktopSession(request: DesktopSessionHTTPRequest): Promise { + return new Promise((resolve, reject) => { + if (request.signal.aborted) { + reject(request.signal.reason); + return; + } + const clientRequest = net.request({ + bypassCustomProtocolHandlers: true, + cache: 'no-store', + credentials: 'omit', + method: request.method, + redirect: 'manual', + session: session.defaultSession, + url: request.url.href, + useSessionCookies: false, + }); + let responded = false; + let finished = false; + let body: Readable | null = null; + const fail = (error: unknown): void => { + if (finished) { + return; + } + finished = true; + request.signal.removeEventListener('abort', onAbort); + if (body == null) { + reject(error); + return; + } + body.destroy(error instanceof Error ? error : new DesktopSessionHTTPAbortedError(request.url.href)); + }; + const finish = (): void => { + finished = true; + request.signal.removeEventListener('abort', onAbort); + }; + const onAbort = (): void => { + clientRequest.abort(); + fail(request.signal.reason); + }; + request.signal.addEventListener('abort', onAbort, {once: true}); + for (const [name, value] of Object.entries(request.headers)) { + if (!isRefusedRequestHeader(name)) { + clientRequest.setHeader(name, value); + } + } + clientRequest.on('redirect', (status, _method, _location, headers) => { + responded = true; + finish(); + clientRequest.abort(); + resolve({ + headers: decodedResponseHeaders(headers), + message: Readable.from([]), + status, + statusText: '', + }); + }); + clientRequest.on('response', (incoming) => { + responded = true; + const source = incoming as unknown as Readable; + const message = new Readable({ + read() { + source.resume(); + }, + destroy(error, callback) { + if (!finished) { + finish(); + clientRequest.abort(); + } + callback(error); + }, + }); + body = message; + source.on('data', (chunk: Buffer) => { + if (!message.push(chunk)) { + source.pause(); + } + }); + source.on('end', () => { + finish(); + message.push(null); + }); + source.on('error', fail); + incoming.on('aborted', () => fail(new DesktopSessionHTTPAbortedError(request.url.href))); + resolve({ + headers: decodedResponseHeaders(incoming.headers), + message, + status: incoming.statusCode, + statusText: incoming.statusMessage, + }); + }); + clientRequest.on('error', fail); + clientRequest.on('abort', () => { + if (!responded) { + fail(new DesktopSessionHTTPAbortedError(request.url.href)); + } + }); + const requestBody = request.body; + if (requestBody == null) { + clientRequest.end(); + return; + } + if (requestBody instanceof Uint8Array) { + clientRequest.end(Buffer.from(requestBody)); + return; + } + clientRequest.chunkedEncoding = true; + requestBody.on('error', (error: Error) => { + clientRequest.abort(); + fail(error); + }); + requestBody.pipe(clientRequest as unknown as Writable); + }); +} diff --git a/fluxer_desktop/src/main/FileDownloads.test.mjs b/fluxer_desktop/src/main/FileDownloads.test.mjs index 08b7da111..6b045da32 100644 --- a/fluxer_desktop/src/main/FileDownloads.test.mjs +++ b/fluxer_desktop/src/main/FileDownloads.test.mjs @@ -82,6 +82,13 @@ async function loadFileDownloads({registerOrigin = true} = {}) { createChildLogger: () => ({debug() {}, info() {}, warn() {}, error() {}}), }, '@fluxer/instance_bootstrap/src/NetworkOrigin': {normalizeHTTPNetworkOrigin: (value) => new URL(value).origin}, + '@electron/main/DesktopSessionHTTP': { + isDirectProxyRoute: (route) => route === 'DIRECT', + resolveDesktopSessionProxy: async () => 'DIRECT', + sendThroughDesktopSession: async () => { + throw new Error('a direct route never reaches the session'); + }, + }, }; const sandbox = { console, diff --git a/fluxer_desktop/src/main/LocalAppProxyClient.ts b/fluxer_desktop/src/main/LocalAppProxyClient.ts index 0c6f9599b..fa6c8f813 100644 --- a/fluxer_desktop/src/main/LocalAppProxyClient.ts +++ b/fluxer_desktop/src/main/LocalAppProxyClient.ts @@ -414,7 +414,7 @@ function contentEncoding(incoming: http.IncomingHttpHeaders): string | null { return normalized.length === 0 ? null : normalized; } -function decodedMessageStream(message: http.IncomingMessage, encoding: string | null): Readable { +function decodedMessageStream(message: Readable, encoding: string | null): Readable { if (encoding == null || encoding === 'identity') { return message; } diff --git a/fluxer_desktop/src/main/LocalAppTestSupport.test.mjs b/fluxer_desktop/src/main/LocalAppTestSupport.test.mjs index 1b7d60eeb..0f77a4839 100644 --- a/fluxer_desktop/src/main/LocalAppTestSupport.test.mjs +++ b/fluxer_desktop/src/main/LocalAppTestSupport.test.mjs @@ -153,7 +153,8 @@ export function installElectronStub(extra = {}) { } } const nativeTheme = {themeSource: 'system'}; - state.electron = {nativeTheme, webContents, webFrameMain, ...extra, app}; + const session = {defaultSession: {resolveProxy: async () => 'DIRECT'}}; + state.electron = {nativeTheme, session, webContents, webFrameMain, ...extra, app}; return registry; } diff --git a/fluxer_desktop/src/main/NativeGatewayTransport.ts b/fluxer_desktop/src/main/NativeGatewayTransport.ts index e687b650c..133807805 100644 --- a/fluxer_desktop/src/main/NativeGatewayTransport.ts +++ b/fluxer_desktop/src/main/NativeGatewayTransport.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {getDesktopOutboundHTTP} from '@electron/main/DesktopOutboundHTTP'; +import {isDirectProxyRoute} from '@electron/main/DesktopSessionHTTP'; import type {GatewayOriginRegistry} from '@electron/main/GatewayOriginRegistry'; import { createGatewaySocketBoundary, @@ -28,6 +29,7 @@ import { NATIVE_GATEWAY_TRANSPORT_CHANNELS, NATIVE_GATEWAY_TRANSPORT_EVENT_CHANNEL, NATIVE_GATEWAY_TRANSPORT_MAX_CONNECTIONS_PER_DOCUMENT, + NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE, type NativeGatewayTransportCreateResult, type NativeGatewayTransportEvent, NativeGatewayTransportEventKind, @@ -61,7 +63,7 @@ class NativeGatewayTransportUnavailableError extends Error { class NativeGatewayTransportProxiedError extends Error { public constructor() { - super('The native gateway transport cannot reach a gateway that resolves through a proxy'); + super(NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE); this.name = 'NativeGatewayTransportProxiedError'; } } @@ -263,7 +265,7 @@ export class DesktopNativeGatewayTransport { private async requireDirectRoute(event: RendererDocumentIpcEvent, url: string): Promise { const route = await this.dependencies.resolveProxy(event, url.replace(/^ws/u, 'http')); - if (route.trim() !== 'DIRECT') { + if (!isDirectProxyRoute(route)) { throw new NativeGatewayTransportProxiedError(); } } diff --git a/fluxer_desktop/src/main/ShellSelfUpdate.ts b/fluxer_desktop/src/main/ShellSelfUpdate.ts index 97181b0aa..af64c03d9 100644 --- a/fluxer_desktop/src/main/ShellSelfUpdate.ts +++ b/fluxer_desktop/src/main/ShellSelfUpdate.ts @@ -10,6 +10,7 @@ import { stageAppImageUpdate, } from '@electron/main/AppImageUpdate'; import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath'; +import {moduleNetworkFetch} from '@electron/main/ModuleNetworkFetch'; import {compareModuleVersions, parseModuleVersion} from '@electron/main/ModuleVersion'; import {getUpdateBaseUrl} from '@electron/main/ShellDownloadFormats'; import type {ShellUpdateCapability, ShellUpdatePlan} from '@electron/main/ShellUpdateCapability'; @@ -18,7 +19,7 @@ import { readVelopackApplyAttempt, recordVelopackApplyAttempt, } from '@electron/main/UpdaterApplyState'; -import {app, autoUpdater} from 'electron'; +import {app, autoUpdater, net} from 'electron'; import type {UpdateInfo, VelopackAsset} from 'velopack'; const requireModule = createRequire(import.meta.url); @@ -148,7 +149,7 @@ async function runVelopackSelfUpdate(control: SelfUpdateControl, hooks: ShellSel } async function fetchPublishedAppImage(): Promise<{version: string; sha256: string | null}> { - const response = await fetch(`${getUpdateBaseUrl()}/latest`, { + const response = await net.fetch(`${getUpdateBaseUrl()}/latest`, { cache: 'no-store', headers: {Accept: 'application/json', 'Cache-Control': 'no-cache'}, }); @@ -199,6 +200,7 @@ async function runAppImageSelfUpdate( target, url: `${getUpdateBaseUrl()}/${published.version}/appimage`, expectedSha256: published.sha256, + fetchImpl: moduleNetworkFetch, onProgress: ({transferred, total}) => { if (control.isSettled() || total <= 0) return; hooks.onDownloading(Math.min(100, (transferred / total) * 100)); diff --git a/fluxer_desktop/src/main/Spellcheck.ts b/fluxer_desktop/src/main/Spellcheck.ts index 9fda93b2c..7cceaf4fa 100644 --- a/fluxer_desktop/src/main/Spellcheck.ts +++ b/fluxer_desktop/src/main/Spellcheck.ts @@ -14,7 +14,7 @@ import { DICTIONARY_SOURCES, type SpellcheckDictionaryCatalogEntry, } from '@electron/main/SpellcheckDictionaries'; -import {app, ipcMain, type Session, type WebContents} from 'electron'; +import {app, ipcMain, net, type Session, type WebContents} from 'electron'; import log from 'electron-log'; import {franc} from 'franc-min'; import {type Hunspell, type HunspellFactory, loadModule} from 'hunspell-asm'; @@ -280,7 +280,7 @@ const downloadDictionaryFile = async ( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), DICTIONARY_DOWNLOAD_TIMEOUT_MS); try { - const response = await fetch(dictionaryFileUrl(dict, fileName), { + const response = await net.fetch(dictionaryFileUrl(dict, fileName), { headers: { Accept: 'application/octet-stream,*/*;q=0.8', }, diff --git a/fluxer_desktop/src/main/Updater.test.mjs b/fluxer_desktop/src/main/Updater.test.mjs index 810653523..594aa6c88 100644 --- a/fluxer_desktop/src/main/Updater.test.mjs +++ b/fluxer_desktop/src/main/Updater.test.mjs @@ -143,6 +143,7 @@ function loadUpdater({ '@electron/common/DesktopIdentity': {DESKTOP_ARTIFACT_PRODUCT_NAME: 'Fluxer-Canary'}, '@electron/common/UserDataPath': {isPortableMode: () => false}, '@electron/main/DesktopTray': {destroyDesktopTray() {}}, + '@electron/main/ModuleNetworkFetch': {moduleNetworkFetch: (input, init) => sandbox.fetch(input, init)}, '@electron/main/LinuxSandbox': {isFlatpakRuntime: () => false}, '@electron/main/Troubleshooting': { relaunchAndExit() { @@ -164,6 +165,7 @@ function loadUpdater({ exit() {}, }, autoUpdater: {on() {}}, + net: {fetch: (input, init) => sandbox.fetch(input, init)}, ipcMain: { handle(channel, handler) { handlers.set(channel, handler); diff --git a/fluxer_desktop/src/main/Updater.ts b/fluxer_desktop/src/main/Updater.ts index 4ef9d972a..eedf753dc 100644 --- a/fluxer_desktop/src/main/Updater.ts +++ b/fluxer_desktop/src/main/Updater.ts @@ -13,6 +13,7 @@ import { sweepAbandonedAppImageUpdates, } from '@electron/main/AppImageUpdate'; import {destroyDesktopTray} from '@electron/main/DesktopTray'; +import {moduleNetworkFetch} from '@electron/main/ModuleNetworkFetch'; import {MANUAL_DESKTOP_FORMATS, type ManualDesktopFormat} from '@electron/main/ShellDownloadFormats'; import {resolveShellUpdatePlan, ShellUpdateCapability} from '@electron/main/ShellUpdateCapability'; import {relaunchAndExit} from '@electron/main/Troubleshooting'; @@ -33,7 +34,7 @@ import { type UpdaterDownloadOption, } from '@electron/main/UpdaterDownloads'; import {setQuitting} from '@electron/main/Window'; -import {app, autoUpdater, type BrowserWindow, ipcMain} from 'electron'; +import {app, autoUpdater, type BrowserWindow, ipcMain, net} from 'electron'; import log from 'electron-log'; import type {UpdateInfo, VelopackAsset} from 'velopack'; @@ -552,7 +553,7 @@ async function fetchManualLatest(options: {forceRefresh?: boolean} = {}): Promis if (!options.forceRefresh && manualLatestCache && now - manualLatestCache.at < MANUAL_CACHE_TTL_MS) { return manualLatestCache.info; } - const response = await fetch(`${UPDATE_BASE_URL}/latest`, { + const response = await net.fetch(`${UPDATE_BASE_URL}/latest`, { cache: 'no-store', headers: { Accept: 'application/json', @@ -628,6 +629,7 @@ async function downloadAppImageUpdate( target, url, expectedSha256, + fetchImpl: moduleNetworkFetch, onProgress: ({transferred, total}) => { const now = Date.now(); const dtMs = now - lastSampleAt; diff --git a/packages/desktop_ipc/src/GatewayTransportContract.ts b/packages/desktop_ipc/src/GatewayTransportContract.ts index da6228f93..6f8c65d46 100644 --- a/packages/desktop_ipc/src/GatewayTransportContract.ts +++ b/packages/desktop_ipc/src/GatewayTransportContract.ts @@ -74,6 +74,9 @@ export const NATIVE_GATEWAY_TRANSPORT_CHANNELS = Object.freeze({ isAvailable: 'native-gateway-transport:is-available', } as const); +export const NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE = + 'The native gateway transport cannot reach a gateway that resolves through a proxy'; + export const NATIVE_GATEWAY_TRANSPORT_EVENT_CHANNEL = 'native-gateway-transport:event'; export const NATIVE_GATEWAY_TRANSPORT_AVAILABLE_RENDERER_ARG = '--fluxer-native-gateway-available=1';