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,
|
isDesktopNativeGatewayTransportAvailable,
|
||||||
} from '@app/features/gateway/transport/GatewayWireTransport';
|
} from '@app/features/gateway/transport/GatewayWireTransport';
|
||||||
import {isDesktopLocalAppDocument} from '@app/features/platform/DesktopLocalAppRuntime';
|
import {isDesktopLocalAppDocument} from '@app/features/platform/DesktopLocalAppRuntime';
|
||||||
import type {
|
import {
|
||||||
NativeGatewayTransportAPI,
|
NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE,
|
||||||
NativeGatewayTransportEvent,
|
type NativeGatewayTransportAPI,
|
||||||
|
type NativeGatewayTransportEvent,
|
||||||
} from '@fluxer/desktop_ipc/src/GatewayTransportContract';
|
} from '@fluxer/desktop_ipc/src/GatewayTransportContract';
|
||||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
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 () => {
|
test('native creates the shell refuses count toward the fallback', async () => {
|
||||||
const stub = createNativeGatewayTransportStub({
|
const stub = createNativeGatewayTransportStub({
|
||||||
create: async () => {
|
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);
|
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 () => {
|
test('an open between failures resets the count', async () => {
|
||||||
const stub = createNativeGatewayTransportStub();
|
const stub = createNativeGatewayTransportStub();
|
||||||
installElectronAPI(stub.api);
|
installElectronAPI(stub.api);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {Logger} from '@app/features/platform/utils/AppLogger';
|
|||||||
import {randomUuid} from '@app/features/platform/utils/RandomUuid';
|
import {randomUuid} from '@app/features/platform/utils/RandomUuid';
|
||||||
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
|
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
|
||||||
import {
|
import {
|
||||||
|
NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE,
|
||||||
type NativeGatewayTransportAPI,
|
type NativeGatewayTransportAPI,
|
||||||
type NativeGatewayTransportCloseRequest,
|
type NativeGatewayTransportCloseRequest,
|
||||||
type NativeGatewayTransportEvent,
|
type NativeGatewayTransportEvent,
|
||||||
@@ -433,7 +434,11 @@ class DesktopNativeGatewayWireTransport extends BaseGatewayWireTransport {
|
|||||||
|
|
||||||
private failOpen(error: unknown): void {
|
private failOpen(error: unknown): void {
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
|
if (describeError(error).includes(NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE)) {
|
||||||
|
switchProxiedGatewayToBrowserTransport();
|
||||||
|
} else {
|
||||||
recordNativeGatewayFailureWithoutOpen();
|
recordNativeGatewayFailureWithoutOpen();
|
||||||
|
}
|
||||||
this.abandonNativeConnection();
|
this.abandonNativeConnection();
|
||||||
this.emitTransportFailure(error);
|
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 {
|
function createDesktopConnectionId(): string {
|
||||||
return `${DESKTOP_CONNECTION_ID_PREFIX}${getRouterState().rendererRouterId}:${randomUuid()}`;
|
return `${DESKTOP_CONNECTION_ID_PREFIX}${getRouterState().rendererRouterId}:${randomUuid()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ describe('AppImageUpdate in-place replacement', () => {
|
|||||||
|
|
||||||
const progress = [];
|
const progress = [];
|
||||||
const staged = await appImageUpdate.stageAppImageUpdate({
|
const staged = await appImageUpdate.stageAppImageUpdate({
|
||||||
|
fetchImpl: fetch,
|
||||||
target: resolved.target,
|
target: resolved.target,
|
||||||
url: `${baseUrl}/appimage`,
|
url: `${baseUrl}/appimage`,
|
||||||
expectedSha256: NEW_SHA256,
|
expectedSha256: NEW_SHA256,
|
||||||
@@ -249,6 +250,7 @@ describe('AppImageUpdate in-place replacement', () => {
|
|||||||
const resolved = appImageUpdate.resolveAppImageTarget();
|
const resolved = appImageUpdate.resolveAppImageTarget();
|
||||||
|
|
||||||
const staged = await appImageUpdate.stageAppImageUpdate({
|
const staged = await appImageUpdate.stageAppImageUpdate({
|
||||||
|
fetchImpl: fetch,
|
||||||
target: resolved.target,
|
target: resolved.target,
|
||||||
url: `${baseUrl}/appimage`,
|
url: `${baseUrl}/appimage`,
|
||||||
expectedSha256: NEW_SHA256,
|
expectedSha256: NEW_SHA256,
|
||||||
@@ -266,6 +268,7 @@ describe('AppImageUpdate in-place replacement', () => {
|
|||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
appImageUpdate.stageAppImageUpdate({
|
appImageUpdate.stageAppImageUpdate({
|
||||||
|
fetchImpl: fetch,
|
||||||
target: resolved.target,
|
target: resolved.target,
|
||||||
url: `${baseUrl}/corrupt`,
|
url: `${baseUrl}/corrupt`,
|
||||||
expectedSha256: NEW_SHA256,
|
expectedSha256: NEW_SHA256,
|
||||||
@@ -289,6 +292,7 @@ describe('AppImageUpdate in-place replacement', () => {
|
|||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
appImageUpdate.stageAppImageUpdate({
|
appImageUpdate.stageAppImageUpdate({
|
||||||
|
fetchImpl: fetch,
|
||||||
target: resolved.target,
|
target: resolved.target,
|
||||||
url: `${baseUrl}/appimage`,
|
url: `${baseUrl}/appimage`,
|
||||||
expectedSha256: '',
|
expectedSha256: '',
|
||||||
@@ -307,6 +311,7 @@ describe('AppImageUpdate in-place replacement', () => {
|
|||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
appImageUpdate.stageAppImageUpdate({
|
appImageUpdate.stageAppImageUpdate({
|
||||||
|
fetchImpl: fetch,
|
||||||
target: resolved.target,
|
target: resolved.target,
|
||||||
url: `${baseUrl}/nothing-here`,
|
url: `${baseUrl}/nothing-here`,
|
||||||
expectedSha256: NEW_SHA256,
|
expectedSha256: NEW_SHA256,
|
||||||
@@ -346,6 +351,7 @@ describe('AppImageUpdate in-place replacement', () => {
|
|||||||
const resolved = appImageUpdate.resolveAppImageTarget();
|
const resolved = appImageUpdate.resolveAppImageTarget();
|
||||||
|
|
||||||
const staged = await appImageUpdate.stageAppImageUpdate({
|
const staged = await appImageUpdate.stageAppImageUpdate({
|
||||||
|
fetchImpl: fetch,
|
||||||
target: resolved.target,
|
target: resolved.target,
|
||||||
url: `${baseUrl}/appimage`,
|
url: `${baseUrl}/appimage`,
|
||||||
expectedSha256: NEW_SHA256,
|
expectedSha256: NEW_SHA256,
|
||||||
@@ -387,6 +393,7 @@ describe('AppImageUpdate in-place replacement', () => {
|
|||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
appImageUpdate.stageAppImageUpdate({
|
appImageUpdate.stageAppImageUpdate({
|
||||||
|
fetchImpl: fetch,
|
||||||
target: resolved.target,
|
target: resolved.target,
|
||||||
url: `${baseUrl}/appimage`,
|
url: `${baseUrl}/appimage`,
|
||||||
expectedSha256: NEW_SHA256,
|
expectedSha256: NEW_SHA256,
|
||||||
@@ -428,6 +435,7 @@ describe('AppImageUpdate in-place replacement', () => {
|
|||||||
const resolved = appImageUpdate.resolveAppImageTarget();
|
const resolved = appImageUpdate.resolveAppImageTarget();
|
||||||
|
|
||||||
const staged = await appImageUpdate.stageAppImageUpdate({
|
const staged = await appImageUpdate.stageAppImageUpdate({
|
||||||
|
fetchImpl: fetch,
|
||||||
target: resolved.target,
|
target: resolved.target,
|
||||||
url: `${baseUrl}/appimage`,
|
url: `${baseUrl}/appimage`,
|
||||||
expectedSha256: NEW_SHA256,
|
expectedSha256: NEW_SHA256,
|
||||||
@@ -465,6 +473,7 @@ describe('AppImageUpdate in-place replacement', () => {
|
|||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
appImageUpdate.stageAppImageUpdate({
|
appImageUpdate.stageAppImageUpdate({
|
||||||
|
fetchImpl: fetch,
|
||||||
target: resolved.target,
|
target: resolved.target,
|
||||||
url: `${baseUrl}/appimage`,
|
url: `${baseUrl}/appimage`,
|
||||||
expectedSha256: NEW_SHA256,
|
expectedSha256: NEW_SHA256,
|
||||||
@@ -597,6 +606,7 @@ describe('AppImageUpdate in-place replacement', () => {
|
|||||||
const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath});
|
const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath});
|
||||||
const resolved = appImageUpdate.resolveAppImageTarget();
|
const resolved = appImageUpdate.resolveAppImageTarget();
|
||||||
const staged = await appImageUpdate.stageAppImageUpdate({
|
const staged = await appImageUpdate.stageAppImageUpdate({
|
||||||
|
fetchImpl: fetch,
|
||||||
target: resolved.target,
|
target: resolved.target,
|
||||||
url: `${baseUrl}/appimage`,
|
url: `${baseUrl}/appimage`,
|
||||||
expectedSha256: NEW_SHA256,
|
expectedSha256: NEW_SHA256,
|
||||||
@@ -619,6 +629,7 @@ describe('AppImageUpdate abandoned staging cleanup', () => {
|
|||||||
const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath});
|
const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath});
|
||||||
const resolved = appImageUpdate.resolveAppImageTarget();
|
const resolved = appImageUpdate.resolveAppImageTarget();
|
||||||
const abandoned = await appImageUpdate.stageAppImageUpdate({
|
const abandoned = await appImageUpdate.stageAppImageUpdate({
|
||||||
|
fetchImpl: fetch,
|
||||||
target: resolved.target,
|
target: resolved.target,
|
||||||
url: `${baseUrl}/appimage`,
|
url: `${baseUrl}/appimage`,
|
||||||
expectedSha256: NEW_SHA256,
|
expectedSha256: NEW_SHA256,
|
||||||
@@ -641,6 +652,7 @@ describe('AppImageUpdate abandoned staging cleanup', () => {
|
|||||||
const resolved = appImageUpdate.resolveAppImageTarget();
|
const resolved = appImageUpdate.resolveAppImageTarget();
|
||||||
const sibling = appImageUpdate.resolveAppImageTarget(siblingPath);
|
const sibling = appImageUpdate.resolveAppImageTarget(siblingPath);
|
||||||
const siblingStaged = await appImageUpdate.stageAppImageUpdate({
|
const siblingStaged = await appImageUpdate.stageAppImageUpdate({
|
||||||
|
fetchImpl: fetch,
|
||||||
target: sibling.target,
|
target: sibling.target,
|
||||||
url: `${baseUrl}/appimage`,
|
url: `${baseUrl}/appimage`,
|
||||||
expectedSha256: NEW_SHA256,
|
expectedSha256: NEW_SHA256,
|
||||||
|
|||||||
@@ -166,14 +166,13 @@ export async function stageAppImageUpdate(options: {
|
|||||||
url: string;
|
url: string;
|
||||||
expectedSha256: string;
|
expectedSha256: string;
|
||||||
onProgress?: (progress: AppImageDownloadProgress) => void;
|
onProgress?: (progress: AppImageDownloadProgress) => void;
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl: typeof fetch;
|
||||||
}): Promise<StagedAppImageUpdate> {
|
}): Promise<StagedAppImageUpdate> {
|
||||||
const expected = options.expectedSha256.trim().toLowerCase();
|
const expected = options.expectedSha256.trim().toLowerCase();
|
||||||
if (!SHA256_PATTERN.test(expected)) {
|
if (!SHA256_PATTERN.test(expected)) {
|
||||||
throw new Error('Update checksum is missing or malformed.');
|
throw new Error('Update checksum is missing or malformed.');
|
||||||
}
|
}
|
||||||
const fetchImpl = options.fetchImpl ?? fetch;
|
const response = await options.fetchImpl(options.url, {cache: 'no-store', redirect: 'follow'});
|
||||||
const response = await fetchImpl(options.url, {cache: 'no-store', redirect: 'follow'});
|
|
||||||
if (!response.ok || response.body == null) {
|
if (!response.ok || response.body == null) {
|
||||||
throw new Error(`Update download failed: ${response.status}`);
|
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', () => {
|
describe('desktop instance launch surfaces', () => {
|
||||||
test('--fluxer-instance is a development-only API endpoint flag', () => {
|
test('--fluxer-instance is a development-only API endpoint flag', () => {
|
||||||
const originalDefaultApp = process.defaultApp;
|
const originalDefaultApp = process.defaultApp;
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ import {isIPv4, isIPv6, type LookupFunction} from 'node:net';
|
|||||||
import {Readable, Transform} from 'node:stream';
|
import {Readable, Transform} from 'node:stream';
|
||||||
import type {ReadableStream as NodeReadableStream} from 'node:stream/web';
|
import type {ReadableStream as NodeReadableStream} from 'node:stream/web';
|
||||||
import {createChildLogger} from '@electron/common/Logger';
|
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';
|
import {normalizeHTTPNetworkOrigin} from '@fluxer/instance_bootstrap/src/NetworkOrigin';
|
||||||
|
|
||||||
const logger = createChildLogger('DesktopOutboundHTTP');
|
const logger = createChildLogger('DesktopOutboundHTTP');
|
||||||
@@ -120,6 +127,8 @@ interface DesktopAnchoredOriginRegistration {
|
|||||||
|
|
||||||
interface DesktopOutboundHTTPOptions {
|
interface DesktopOutboundHTTPOptions {
|
||||||
readonly resolveHostAddresses?: DesktopHostAddressResolver;
|
readonly resolveHostAddresses?: DesktopHostAddressResolver;
|
||||||
|
readonly resolveProxy?: DesktopProxyResolver;
|
||||||
|
readonly sendThroughSession?: DesktopSessionHTTPSender;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DesktopOutboundGETRequest {
|
interface DesktopOutboundGETRequest {
|
||||||
@@ -130,7 +139,7 @@ interface DesktopOutboundGETRequest {
|
|||||||
|
|
||||||
export interface DesktopOutboundHTTPMessage {
|
export interface DesktopOutboundHTTPMessage {
|
||||||
readonly headers: http.IncomingHttpHeaders;
|
readonly headers: http.IncomingHttpHeaders;
|
||||||
readonly message: http.IncomingMessage;
|
readonly message: Readable;
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
readonly statusText: string;
|
readonly statusText: string;
|
||||||
readonly url: URL;
|
readonly url: URL;
|
||||||
@@ -432,6 +441,11 @@ function isPublicPinnedAddress(pinned: PinnedAddress): boolean {
|
|||||||
return pinned.family === 4 ? isPublicIPv4Address(pinned.address) : isPublicIPv6Address(pinned.address);
|
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 {
|
export function requireDesktopHTTPOrigin(value: string): string {
|
||||||
const normalized = normalizeHTTPNetworkOrigin(value);
|
const normalized = normalizeHTTPNetworkOrigin(value);
|
||||||
if (normalized == null) {
|
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 {
|
export class DesktopOutboundHTTP {
|
||||||
private readonly httpAgent = new http.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS});
|
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});
|
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 registeredRequirements = new Map<string, DesktopAddressRequirement>();
|
||||||
private readonly originRequestsInFlightByService = new Map<string, number>();
|
private readonly originRequestsInFlightByService = new Map<string, number>();
|
||||||
private readonly resolveHostAddresses: DesktopHostAddressResolver;
|
private readonly resolveHostAddresses: DesktopHostAddressResolver;
|
||||||
|
private readonly resolveProxy: DesktopProxyResolver;
|
||||||
|
private readonly sendThroughSession: DesktopSessionHTTPSender;
|
||||||
private inFlight = 0;
|
private inFlight = 0;
|
||||||
private originRequestsInFlight = 0;
|
private originRequestsInFlight = 0;
|
||||||
private activeResolutions = 0;
|
private activeResolutions = 0;
|
||||||
@@ -710,6 +737,8 @@ export class DesktopOutboundHTTP {
|
|||||||
|
|
||||||
public constructor(options: DesktopOutboundHTTPOptions = {}) {
|
public constructor(options: DesktopOutboundHTTPOptions = {}) {
|
||||||
this.resolveHostAddresses = options.resolveHostAddresses ?? lookupAllAddresses;
|
this.resolveHostAddresses = options.resolveHostAddresses ?? lookupAllAddresses;
|
||||||
|
this.resolveProxy = options.resolveProxy ?? resolveDesktopSessionProxy;
|
||||||
|
this.sendThroughSession = options.sendThroughSession ?? sendThroughDesktopSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(request: DesktopOutboundGETRequest): Promise<DesktopOutboundHTTPMessage> {
|
public async get(request: DesktopOutboundGETRequest): Promise<DesktopOutboundHTTPMessage> {
|
||||||
@@ -730,6 +759,15 @@ export class DesktopOutboundHTTP {
|
|||||||
if (requirement === DesktopAddressRequirement.PUBLIC && request.url.protocol !== 'https:') {
|
if (requirement === DesktopAddressRequirement.PUBLIC && request.url.protocol !== 'https:') {
|
||||||
throw blocked(DesktopOutboundBlockReason.INSECURE_TRANSPORT, request.context, request.url.hostname);
|
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);
|
const pinned = await this.pinAddress(request.url.hostname, requirement, request.context);
|
||||||
return await this.issue(request, pinned, release);
|
return await this.issue(request, pinned, release);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -779,6 +817,11 @@ export class DesktopOutboundHTTP {
|
|||||||
canonicalAnchor: string,
|
canonicalAnchor: string,
|
||||||
unresolvedAnchorRequirement: DesktopAddressRequirement | null,
|
unresolvedAnchorRequirement: DesktopAddressRequirement | null,
|
||||||
): Promise<DesktopAddressRequirement> {
|
): Promise<DesktopAddressRequirement> {
|
||||||
|
if (!(await this.routesDirect(new URL(canonicalAnchor)))) {
|
||||||
|
return proxiedHostScope(new URL(canonicalAnchor).hostname) === DesktopOriginAddressScope.PUBLIC
|
||||||
|
? DesktopAddressRequirement.PUBLIC
|
||||||
|
: DesktopAddressRequirement.ANY;
|
||||||
|
}
|
||||||
let anchor: DesktopOriginAddressBinding;
|
let anchor: DesktopOriginAddressBinding;
|
||||||
try {
|
try {
|
||||||
anchor = await this.ensureBinding(canonicalAnchor, DesktopAddressRequirement.ANY);
|
anchor = await this.ensureBinding(canonicalAnchor, DesktopAddressRequirement.ANY);
|
||||||
@@ -825,6 +868,17 @@ export class DesktopOutboundHTTP {
|
|||||||
throw new DesktopOutboundHTTPRequestBodyLimitError(maximumRequestBodyBytes);
|
throw new DesktopOutboundHTTPRequestBodyLimitError(maximumRequestBodyBytes);
|
||||||
}
|
}
|
||||||
const requirement = this.requirementFor(origin, request.originTrust);
|
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);
|
const binding = await this.ensureBinding(origin, requirement);
|
||||||
if (binding.scope === DesktopOriginAddressScope.PUBLIC && target.protocol !== 'https:') {
|
if (binding.scope === DesktopOriginAddressScope.PUBLIC && target.protocol !== 'https:') {
|
||||||
throw new DesktopOutboundHTTPInsecureTransportError(origin);
|
throw new DesktopOutboundHTTPInsecureTransportError(origin);
|
||||||
@@ -860,6 +914,107 @@ export class DesktopOutboundHTTP {
|
|||||||
this.originRequestsInFlightByService.clear();
|
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(
|
private async pinAddress(
|
||||||
hostname: string,
|
hostname: string,
|
||||||
requirement: DesktopAddressRequirement,
|
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() {}}),
|
createChildLogger: () => ({debug() {}, info() {}, warn() {}, error() {}}),
|
||||||
},
|
},
|
||||||
'@fluxer/instance_bootstrap/src/NetworkOrigin': {normalizeHTTPNetworkOrigin: (value) => new URL(value).origin},
|
'@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 = {
|
const sandbox = {
|
||||||
console,
|
console,
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ function contentEncoding(incoming: http.IncomingHttpHeaders): string | null {
|
|||||||
return normalized.length === 0 ? null : normalized;
|
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') {
|
if (encoding == null || encoding === 'identity') {
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,8 @@ export function installElectronStub(extra = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const nativeTheme = {themeSource: 'system'};
|
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;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {getDesktopOutboundHTTP} from '@electron/main/DesktopOutboundHTTP';
|
import {getDesktopOutboundHTTP} from '@electron/main/DesktopOutboundHTTP';
|
||||||
|
import {isDirectProxyRoute} from '@electron/main/DesktopSessionHTTP';
|
||||||
import type {GatewayOriginRegistry} from '@electron/main/GatewayOriginRegistry';
|
import type {GatewayOriginRegistry} from '@electron/main/GatewayOriginRegistry';
|
||||||
import {
|
import {
|
||||||
createGatewaySocketBoundary,
|
createGatewaySocketBoundary,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
NATIVE_GATEWAY_TRANSPORT_CHANNELS,
|
NATIVE_GATEWAY_TRANSPORT_CHANNELS,
|
||||||
NATIVE_GATEWAY_TRANSPORT_EVENT_CHANNEL,
|
NATIVE_GATEWAY_TRANSPORT_EVENT_CHANNEL,
|
||||||
NATIVE_GATEWAY_TRANSPORT_MAX_CONNECTIONS_PER_DOCUMENT,
|
NATIVE_GATEWAY_TRANSPORT_MAX_CONNECTIONS_PER_DOCUMENT,
|
||||||
|
NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE,
|
||||||
type NativeGatewayTransportCreateResult,
|
type NativeGatewayTransportCreateResult,
|
||||||
type NativeGatewayTransportEvent,
|
type NativeGatewayTransportEvent,
|
||||||
NativeGatewayTransportEventKind,
|
NativeGatewayTransportEventKind,
|
||||||
@@ -61,7 +63,7 @@ class NativeGatewayTransportUnavailableError extends Error {
|
|||||||
|
|
||||||
class NativeGatewayTransportProxiedError extends Error {
|
class NativeGatewayTransportProxiedError extends Error {
|
||||||
public constructor() {
|
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';
|
this.name = 'NativeGatewayTransportProxiedError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +265,7 @@ export class DesktopNativeGatewayTransport {
|
|||||||
|
|
||||||
private async requireDirectRoute(event: RendererDocumentIpcEvent, url: string): Promise<void> {
|
private async requireDirectRoute(event: RendererDocumentIpcEvent, url: string): Promise<void> {
|
||||||
const route = await this.dependencies.resolveProxy(event, url.replace(/^ws/u, 'http'));
|
const route = await this.dependencies.resolveProxy(event, url.replace(/^ws/u, 'http'));
|
||||||
if (route.trim() !== 'DIRECT') {
|
if (!isDirectProxyRoute(route)) {
|
||||||
throw new NativeGatewayTransportProxiedError();
|
throw new NativeGatewayTransportProxiedError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
stageAppImageUpdate,
|
stageAppImageUpdate,
|
||||||
} from '@electron/main/AppImageUpdate';
|
} from '@electron/main/AppImageUpdate';
|
||||||
import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath';
|
import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath';
|
||||||
|
import {moduleNetworkFetch} from '@electron/main/ModuleNetworkFetch';
|
||||||
import {compareModuleVersions, parseModuleVersion} from '@electron/main/ModuleVersion';
|
import {compareModuleVersions, parseModuleVersion} from '@electron/main/ModuleVersion';
|
||||||
import {getUpdateBaseUrl} from '@electron/main/ShellDownloadFormats';
|
import {getUpdateBaseUrl} from '@electron/main/ShellDownloadFormats';
|
||||||
import type {ShellUpdateCapability, ShellUpdatePlan} from '@electron/main/ShellUpdateCapability';
|
import type {ShellUpdateCapability, ShellUpdatePlan} from '@electron/main/ShellUpdateCapability';
|
||||||
@@ -18,7 +19,7 @@ import {
|
|||||||
readVelopackApplyAttempt,
|
readVelopackApplyAttempt,
|
||||||
recordVelopackApplyAttempt,
|
recordVelopackApplyAttempt,
|
||||||
} from '@electron/main/UpdaterApplyState';
|
} from '@electron/main/UpdaterApplyState';
|
||||||
import {app, autoUpdater} from 'electron';
|
import {app, autoUpdater, net} from 'electron';
|
||||||
import type {UpdateInfo, VelopackAsset} from 'velopack';
|
import type {UpdateInfo, VelopackAsset} from 'velopack';
|
||||||
|
|
||||||
const requireModule = createRequire(import.meta.url);
|
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}> {
|
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',
|
cache: 'no-store',
|
||||||
headers: {Accept: 'application/json', 'Cache-Control': 'no-cache'},
|
headers: {Accept: 'application/json', 'Cache-Control': 'no-cache'},
|
||||||
});
|
});
|
||||||
@@ -199,6 +200,7 @@ async function runAppImageSelfUpdate(
|
|||||||
target,
|
target,
|
||||||
url: `${getUpdateBaseUrl()}/${published.version}/appimage`,
|
url: `${getUpdateBaseUrl()}/${published.version}/appimage`,
|
||||||
expectedSha256: published.sha256,
|
expectedSha256: published.sha256,
|
||||||
|
fetchImpl: moduleNetworkFetch,
|
||||||
onProgress: ({transferred, total}) => {
|
onProgress: ({transferred, total}) => {
|
||||||
if (control.isSettled() || total <= 0) return;
|
if (control.isSettled() || total <= 0) return;
|
||||||
hooks.onDownloading(Math.min(100, (transferred / total) * 100));
|
hooks.onDownloading(Math.min(100, (transferred / total) * 100));
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
DICTIONARY_SOURCES,
|
DICTIONARY_SOURCES,
|
||||||
type SpellcheckDictionaryCatalogEntry,
|
type SpellcheckDictionaryCatalogEntry,
|
||||||
} from '@electron/main/SpellcheckDictionaries';
|
} 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 log from 'electron-log';
|
||||||
import {franc} from 'franc-min';
|
import {franc} from 'franc-min';
|
||||||
import {type Hunspell, type HunspellFactory, loadModule} from 'hunspell-asm';
|
import {type Hunspell, type HunspellFactory, loadModule} from 'hunspell-asm';
|
||||||
@@ -280,7 +280,7 @@ const downloadDictionaryFile = async (
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), DICTIONARY_DOWNLOAD_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), DICTIONARY_DOWNLOAD_TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(dictionaryFileUrl(dict, fileName), {
|
const response = await net.fetch(dictionaryFileUrl(dict, fileName), {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/octet-stream,*/*;q=0.8',
|
Accept: 'application/octet-stream,*/*;q=0.8',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ function loadUpdater({
|
|||||||
'@electron/common/DesktopIdentity': {DESKTOP_ARTIFACT_PRODUCT_NAME: 'Fluxer-Canary'},
|
'@electron/common/DesktopIdentity': {DESKTOP_ARTIFACT_PRODUCT_NAME: 'Fluxer-Canary'},
|
||||||
'@electron/common/UserDataPath': {isPortableMode: () => false},
|
'@electron/common/UserDataPath': {isPortableMode: () => false},
|
||||||
'@electron/main/DesktopTray': {destroyDesktopTray() {}},
|
'@electron/main/DesktopTray': {destroyDesktopTray() {}},
|
||||||
|
'@electron/main/ModuleNetworkFetch': {moduleNetworkFetch: (input, init) => sandbox.fetch(input, init)},
|
||||||
'@electron/main/LinuxSandbox': {isFlatpakRuntime: () => false},
|
'@electron/main/LinuxSandbox': {isFlatpakRuntime: () => false},
|
||||||
'@electron/main/Troubleshooting': {
|
'@electron/main/Troubleshooting': {
|
||||||
relaunchAndExit() {
|
relaunchAndExit() {
|
||||||
@@ -164,6 +165,7 @@ function loadUpdater({
|
|||||||
exit() {},
|
exit() {},
|
||||||
},
|
},
|
||||||
autoUpdater: {on() {}},
|
autoUpdater: {on() {}},
|
||||||
|
net: {fetch: (input, init) => sandbox.fetch(input, init)},
|
||||||
ipcMain: {
|
ipcMain: {
|
||||||
handle(channel, handler) {
|
handle(channel, handler) {
|
||||||
handlers.set(channel, handler);
|
handlers.set(channel, handler);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
sweepAbandonedAppImageUpdates,
|
sweepAbandonedAppImageUpdates,
|
||||||
} from '@electron/main/AppImageUpdate';
|
} from '@electron/main/AppImageUpdate';
|
||||||
import {destroyDesktopTray} from '@electron/main/DesktopTray';
|
import {destroyDesktopTray} from '@electron/main/DesktopTray';
|
||||||
|
import {moduleNetworkFetch} from '@electron/main/ModuleNetworkFetch';
|
||||||
import {MANUAL_DESKTOP_FORMATS, type ManualDesktopFormat} from '@electron/main/ShellDownloadFormats';
|
import {MANUAL_DESKTOP_FORMATS, type ManualDesktopFormat} from '@electron/main/ShellDownloadFormats';
|
||||||
import {resolveShellUpdatePlan, ShellUpdateCapability} from '@electron/main/ShellUpdateCapability';
|
import {resolveShellUpdatePlan, ShellUpdateCapability} from '@electron/main/ShellUpdateCapability';
|
||||||
import {relaunchAndExit} from '@electron/main/Troubleshooting';
|
import {relaunchAndExit} from '@electron/main/Troubleshooting';
|
||||||
@@ -33,7 +34,7 @@ import {
|
|||||||
type UpdaterDownloadOption,
|
type UpdaterDownloadOption,
|
||||||
} from '@electron/main/UpdaterDownloads';
|
} from '@electron/main/UpdaterDownloads';
|
||||||
import {setQuitting} from '@electron/main/Window';
|
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 log from 'electron-log';
|
||||||
import type {UpdateInfo, VelopackAsset} from 'velopack';
|
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) {
|
if (!options.forceRefresh && manualLatestCache && now - manualLatestCache.at < MANUAL_CACHE_TTL_MS) {
|
||||||
return manualLatestCache.info;
|
return manualLatestCache.info;
|
||||||
}
|
}
|
||||||
const response = await fetch(`${UPDATE_BASE_URL}/latest`, {
|
const response = await net.fetch(`${UPDATE_BASE_URL}/latest`, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
@@ -628,6 +629,7 @@ async function downloadAppImageUpdate(
|
|||||||
target,
|
target,
|
||||||
url,
|
url,
|
||||||
expectedSha256,
|
expectedSha256,
|
||||||
|
fetchImpl: moduleNetworkFetch,
|
||||||
onProgress: ({transferred, total}) => {
|
onProgress: ({transferred, total}) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const dtMs = now - lastSampleAt;
|
const dtMs = now - lastSampleAt;
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ export const NATIVE_GATEWAY_TRANSPORT_CHANNELS = Object.freeze({
|
|||||||
isAvailable: 'native-gateway-transport:is-available',
|
isAvailable: 'native-gateway-transport:is-available',
|
||||||
} as const);
|
} 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_EVENT_CHANNEL = 'native-gateway-transport:event';
|
||||||
|
|
||||||
export const NATIVE_GATEWAY_TRANSPORT_AVAILABLE_RENDERER_ARG = '--fluxer-native-gateway-available=1';
|
export const NATIVE_GATEWAY_TRANSPORT_AVAILABLE_RENDERER_ARG = '--fluxer-native-gateway-available=1';
|
||||||
|
|||||||
Reference in new issue
Block a user