mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(desktop): honour the system proxy in main-process requests (#3270)
This commit is contained in:
1 parent
6de6be2358
commit
3071bc7525
16 files changed
+541
-18
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
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()}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -166,14 +166,13 @@ export async function stageAppImageUpdate(options: {
|
||||
url: string;
|
||||
expectedSha256: string;
|
||||
onProgress?: (progress: AppImageDownloadProgress) => void;
|
||||
fetchImpl?: typeof fetch;
|
||||
fetchImpl: typeof fetch;
|
||||
}): Promise<StagedAppImageUpdate> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<string, string>>;
|
||||
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<string, DesktopAddressRequirement>();
|
||||
private readonly originRequestsInFlightByService = new Map<string, number>();
|
||||
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<DesktopOutboundHTTPMessage> {
|
||||
@@ -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<DesktopAddressRequirement> {
|
||||
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<boolean> {
|
||||
return isDirectProxyRoute(await this.resolveProxy(url.href));
|
||||
}
|
||||
|
||||
private issueProxied(request: DesktopOutboundGETRequest, release: () => void): Promise<DesktopOutboundHTTPMessage> {
|
||||
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<DesktopOutboundHTTPMessage> {
|
||||
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<DesktopOutboundHTTPMessage> {
|
||||
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<Uint8Array>);
|
||||
source.on('error', (error: Error) => limit.destroy(error));
|
||||
return source.pipe(limit);
|
||||
}
|
||||
|
||||
private async pinAddress(
|
||||
hostname: string,
|
||||
requirement: DesktopAddressRequirement,
|
||||
|
||||
@@ -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<string> = 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<Record<string, string>>;
|
||||
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<DesktopSessionHTTPResponse>;
|
||||
|
||||
export type DesktopProxyResolver = (url: string) => Promise<string>;
|
||||
|
||||
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<string> {
|
||||
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<string, string | Array<string>>): 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<DesktopSessionHTTPResponse> {
|
||||
return new Promise<DesktopSessionHTTPResponse>((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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
const route = await this.dependencies.resolveProxy(event, url.replace(/^ws/u, 'http'));
|
||||
if (route.trim() !== 'DIRECT') {
|
||||
if (!isDirectProxyRoute(route)) {
|
||||
throw new NativeGatewayTransportProxiedError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user