mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44b54d1d9c | ||
|
|
9fab3672ad | ||
|
|
0670daaa33 | ||
|
|
17fe54d146 | ||
|
|
1d96b9b59b |
No files matched your search
@@ -8,10 +8,14 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Set proxy environment variables inside the iOS Simulator for `--egress local` sessions, so clients that read them (gRPC, libcurl) also exit from the egress client. ([#4390](https://github.com/expo/eas-cli/pull/4390) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools] Log which destinations the device host's own processes and the simulator reach during `--egress local` sessions, through a log-only pf anchor, to size the host list for a fail-closed mode. ([#4391](https://github.com/expo/eas-cli/pull/4391) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.3.0](https://github.com/expo/eas-cli/releases/tag/v24.3.0) - 2026-09-11
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
@@ -3,6 +3,7 @@ import spawn from '@expo/turtle-spawn';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { configureSimulatorProxyEnvironmentAsync } from '../../utils/localEgress';
|
||||
import { createStartIosSimulatorBuildFunction } from '../startIosSimulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -22,8 +23,13 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
configureSimulatorProxyEnvironmentAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedUtils = jest.mocked(IosSimulatorUtils);
|
||||
const mockedConfigureProxyEnvironment = jest.mocked(configureSimulatorProxyEnvironmentAsync);
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>) {
|
||||
const logger = createMockLogger();
|
||||
@@ -44,6 +50,7 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
|
||||
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
|
||||
mockedConfigureProxyEnvironment.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('does not enable accessibility settings by default', async () => {
|
||||
@@ -110,6 +117,27 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('configures the local egress proxy environment once each device is ready', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-1' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-2' as any });
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
expect(mockedConfigureProxyEnvironment.mock.calls.map(([{ udid }]) => udid)).toEqual([
|
||||
'base',
|
||||
'clone-1',
|
||||
'clone-2',
|
||||
]);
|
||||
for (const [callIndex] of mockedConfigureProxyEnvironment.mock.calls.entries()) {
|
||||
const readyCallOrder = mockedUtils.waitForReadyAsync.mock.invocationCallOrder[callIndex];
|
||||
const configureCallOrder =
|
||||
mockedConfigureProxyEnvironment.mock.invocationCallOrder[callIndex];
|
||||
expect(configureCallOrder).toBeGreaterThan(readyCallOrder);
|
||||
}
|
||||
});
|
||||
|
||||
it('continues when disabling apsd fails', async () => {
|
||||
mockedUtils.disableApsdAsync.mockRejectedValue(new Error('apsd disable failed'));
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ jest.mock('../../utils/localEgress', () => ({
|
||||
startChiselServerAsync: jest.fn(),
|
||||
writeLocalEgressHandoffAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressInventory', () => ({
|
||||
startLocalEgressInventoryAsync: jest.fn().mockResolvedValue({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
findAvailablePortAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { minBy } from 'lodash';
|
||||
|
||||
import { configureSimulatorProxyEnvironmentAsync } from '../utils/localEgress';
|
||||
|
||||
import {
|
||||
IosSimulatorName,
|
||||
IosSimulatorUtils,
|
||||
@@ -85,6 +87,7 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
}
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
|
||||
await configureSimulatorProxyEnvironmentAsync({ udid, env, logger });
|
||||
|
||||
logger.info('');
|
||||
|
||||
@@ -131,6 +134,7 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
udid: cloneUdid,
|
||||
env,
|
||||
});
|
||||
await configureSimulatorProxyEnvironmentAsync({ udid: cloneUdid, env, logger });
|
||||
|
||||
logger.info(`${cloneDeviceName} is ready.`);
|
||||
logger.info('');
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../utils/localEgress';
|
||||
import {
|
||||
type LocalEgressInventoryHandle,
|
||||
startLocalEgressInventoryAsync,
|
||||
} from '../utils/localEgressInventory';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
type NgrokTunnelHandle,
|
||||
@@ -75,9 +79,10 @@ function awaitLocalEgressAcquisitionAsync<T>(
|
||||
/**
|
||||
* Points the device host's system HTTP(S) proxy at the EAS CLI's egress client.
|
||||
* Must run before `eas/start_ios_simulator`: the simulator reads the system proxy
|
||||
* at boot. Nothing else on the host changes, so requests from libraries that
|
||||
* bypass the system proxy are not covered; the shared session monitor reports
|
||||
* them. The shared session cleanup releases the resources started here when
|
||||
* at boot. Once a simulator is ready, `eas/start_ios_simulator` also sets proxy
|
||||
* environment variables inside it for clients that read them (gRPC, libcurl).
|
||||
* Libraries that ignore both are not covered; the shared session monitor
|
||||
* reports them. The shared session cleanup releases the resources started here when
|
||||
* the session ends, with a job finalizer as a fallback.
|
||||
*/
|
||||
export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
@@ -93,6 +98,7 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
let workDir: string | undefined;
|
||||
let server: DetachedProcessHandle | undefined;
|
||||
let tunnel: NgrokTunnelHandle | undefined;
|
||||
let inventory: LocalEgressInventoryHandle | undefined;
|
||||
let finishSetup!: () => void;
|
||||
const setupFinished = new Promise<void>(resolve => {
|
||||
finishSetup = resolve;
|
||||
@@ -102,6 +108,7 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
const lifetimeSignal = registerLocalEgressResources(async () => {
|
||||
await setupFinished;
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve().then(() => inventory?.stopAsync()),
|
||||
Promise.resolve().then(() => tunnel?.stopAsync()),
|
||||
Promise.resolve().then(() => server?.stopAsync()),
|
||||
]);
|
||||
@@ -180,11 +187,28 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
if (process.env.ENVIRONMENT !== 'development') {
|
||||
// Observation only: a log-only pf anchor and a sampler of the worker's
|
||||
// own connections, to measure which hosts a future block rule must
|
||||
// keep open. Never fails the session.
|
||||
try {
|
||||
inventory = await startLocalEgressInventoryAsync({ env, logger });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress inventory could not start, so this session will not report which ' +
|
||||
'destinations bypass the proxy. The session itself is unaffected.'
|
||||
);
|
||||
}
|
||||
}
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(
|
||||
`Local egress is configured on network service "${service}". HTTP(S) and WebSocket ` +
|
||||
'requests that honor the system proxy (WebKit, URLSession and other CFNetwork clients) ' +
|
||||
'fail until the EAS CLI egress client connects, then exit from that machine. Requests ' +
|
||||
'from libraries that bypass the system proxy are not covered and exit from this worker. ' +
|
||||
'fail until the EAS CLI egress client connects, then exit from that machine. Once the ' +
|
||||
'Simulator is ready, proxy environment variables are set inside it for clients that read ' +
|
||||
'them (gRPC, libcurl). Requests from libraries that ignore both are not covered and exit ' +
|
||||
'from this worker. ' +
|
||||
'Connection sampling may report these bypasses, but can miss short connections and unconnected UDP traffic.'
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -7,10 +8,13 @@ import { spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_NO_PROXY,
|
||||
LOCAL_EGRESS_PROXY_PORT,
|
||||
buildEgressRemoteConfigFields,
|
||||
buildLocalEgressSimulatorEnvironment,
|
||||
buildNetworksetupProxyArgs,
|
||||
collectSimulatorProcessIds,
|
||||
configureSimulatorProxyEnvironmentAsync,
|
||||
createChiselAuthfileContents,
|
||||
getChiselAssetName,
|
||||
getChiselDownloadUrl,
|
||||
@@ -313,6 +317,127 @@ describe('local egress handoff', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildLocalEgressSimulatorEnvironment, () => {
|
||||
it('points every proxy variable at the loopback port and excludes loopback', () => {
|
||||
expect(buildLocalEgressSimulatorEnvironment(8899)).toEqual({
|
||||
http_proxy: 'http://127.0.0.1:8899',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
HTTP_PROXY: 'http://127.0.0.1:8899',
|
||||
HTTPS_PROXY: 'http://127.0.0.1:8899',
|
||||
grpc_proxy: 'http://127.0.0.1:8899',
|
||||
no_proxy: LOCAL_EGRESS_NO_PROXY,
|
||||
NO_PROXY: LOCAL_EGRESS_NO_PROXY,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(configureSimulatorProxyEnvironmentAsync, () => {
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
let tempDir: string;
|
||||
let logger: bunyan;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockedSpawn.mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'local-egress-env-test-'));
|
||||
logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does nothing when no local egress session is active', async () => {
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath: path.join(tempDir, 'missing.json'),
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets the proxy environment in the simulator when a handoff exists', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(
|
||||
{ url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 },
|
||||
handoffPath
|
||||
);
|
||||
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
const setenvCalls = mockedSpawn.mock.calls.map(([, args]) => args);
|
||||
expect(setenvCalls).toEqual(
|
||||
Object.entries(buildLocalEgressSimulatorEnvironment(8899)).map(([name, value]) => [
|
||||
'simctl',
|
||||
'spawn',
|
||||
'test-udid',
|
||||
'launchctl',
|
||||
'setenv',
|
||||
name,
|
||||
value,
|
||||
])
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('proxy environment variables set')
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and continues when launchctl fails', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(
|
||||
{ url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 },
|
||||
handoffPath
|
||||
);
|
||||
mockedSpawn.mockRejectedValue(new Error('launchctl failed'));
|
||||
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringContaining('will bypass local egress and exit from this worker')
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and continues when the handoff is malformed', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify({ url: 'x' }), 'utf8');
|
||||
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringContaining('could not read the local egress handoff')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startChiselServerAsync, () => {
|
||||
const options = {
|
||||
chiselPath: '/tmp/chisel',
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
LOCAL_EGRESS_PF_ANCHOR,
|
||||
LocalEgressInventoryTracker,
|
||||
PFLOG_ATTRIBUTION_GRACE_MS,
|
||||
buildInventoryAnchorRules,
|
||||
collectDescendantProcessIds,
|
||||
parsePflogLine,
|
||||
parseWorkerConnections,
|
||||
} from '../localEgressInventory';
|
||||
|
||||
describe(buildInventoryAnchorRules, () => {
|
||||
it('logs, never blocks, only for the given uid, and skips loopback', () => {
|
||||
const rules = buildInventoryAnchorRules({ uid: 501 });
|
||||
expect(rules).toBe(
|
||||
[
|
||||
'pass out log (user) inet proto tcp from any to ! 127.0.0.0/8 user 501',
|
||||
'pass out log (user) inet proto udp from any to ! 127.0.0.0/8 user 501',
|
||||
'pass out log (user) inet6 proto tcp from any to ! ::1 user 501',
|
||||
'pass out log (user) inet6 proto udp from any to ! ::1 user 501',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
expect(rules).not.toMatch(/block/);
|
||||
});
|
||||
|
||||
it('keeps the anchor under the wildcard the stock ruleset evaluates', () => {
|
||||
expect(LOCAL_EGRESS_PF_ANCHOR.startsWith('com.apple/')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parsePflogLine, () => {
|
||||
it('parses a TCP flow with uid and pid', () => {
|
||||
expect(
|
||||
parsePflogLine(
|
||||
'00:00:01.000000 rule 0/0(match): pass out on en0: [uid 501, pid 4242] 10.0.0.5.54321 > 93.184.216.34.443: Flags [S], seq 1, win 65535, length 0'
|
||||
)
|
||||
).toEqual({
|
||||
action: 'pass',
|
||||
protocol: 'TCP',
|
||||
remote: '93.184.216.34:443',
|
||||
uid: 501,
|
||||
pid: 4242,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a UDP flow without ids', () => {
|
||||
expect(
|
||||
parsePflogLine(
|
||||
'00:00:00.000100 rule 0/0(match): pass out on en0: 10.0.0.5.60000 > 8.8.8.8.53: UDP, length 40'
|
||||
)
|
||||
).toEqual({ action: 'pass', protocol: 'UDP', remote: '8.8.8.8:53', uid: null, pid: null });
|
||||
});
|
||||
|
||||
it('parses an IPv6 peer into bracket form', () => {
|
||||
expect(
|
||||
parsePflogLine(
|
||||
'00:00:00.000100 rule 0/0(match): block out on en0: [uid 501] 2001:db8::5.50000 > 2606:4700::1.443: Flags [S], length 0'
|
||||
)
|
||||
).toEqual({
|
||||
action: 'block',
|
||||
protocol: 'TCP',
|
||||
remote: '[2606:4700::1]:443',
|
||||
uid: 501,
|
||||
pid: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores lines that are not flows', () => {
|
||||
expect(parsePflogLine('tcpdump: listening on pflog0, link-type PFLOG')).toBeNull();
|
||||
expect(parsePflogLine('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(collectDescendantProcessIds, () => {
|
||||
const ps = [
|
||||
' 1 0 /sbin/launchd',
|
||||
' 3758 1 node',
|
||||
' 3801 3758 xcrun',
|
||||
' 3802 3801 simctl',
|
||||
' 4000 1 launchd_sim',
|
||||
' 4001 4000 SpringBoard',
|
||||
' 4100 3758 bun',
|
||||
].join('\n');
|
||||
|
||||
it('returns the root and every descendant, not simulator processes', () => {
|
||||
expect(collectDescendantProcessIds(ps, 3758).sort()).toEqual([3758, 3801, 3802, 4100].sort());
|
||||
});
|
||||
|
||||
it('returns only the root when it has no children', () => {
|
||||
expect(collectDescendantProcessIds(ps, 4001)).toEqual([4001]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseWorkerConnections, () => {
|
||||
const lsof = [
|
||||
'p3758',
|
||||
'cnode',
|
||||
'f20',
|
||||
'PTCP',
|
||||
'n10.0.0.5:54000->34.120.1.1:443',
|
||||
'TST=ESTABLISHED',
|
||||
'f21',
|
||||
'PTCP',
|
||||
'n127.0.0.1:64051->127.0.0.1:52000',
|
||||
'TST=ESTABLISHED',
|
||||
'f22',
|
||||
'PTCP',
|
||||
'n*:49786',
|
||||
'TST=LISTEN',
|
||||
'p4001',
|
||||
'cSpringBoard',
|
||||
'f9',
|
||||
'PTCP',
|
||||
'n10.0.0.5:54100->17.253.1.1:443',
|
||||
'TST=ESTABLISHED',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
it('returns non-loopback connections for the given pids only', () => {
|
||||
expect(parseWorkerConnections(lsof, new Set([3758]))).toEqual([
|
||||
{ pid: 3758, command: 'node', protocol: 'TCP', remote: '34.120.1.1:443' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(LocalEgressInventoryTracker, () => {
|
||||
const ps = [' 3758 1 node', ' 4001 4000 /path/to/SpringBoard'].join('\n');
|
||||
const workerFlow = {
|
||||
action: 'pass',
|
||||
protocol: 'TCP' as const,
|
||||
remote: '34.120.1.1:443',
|
||||
uid: 501,
|
||||
pid: null,
|
||||
};
|
||||
const connection = { pid: 3758, command: 'node', protocol: 'TCP', remote: '34.120.1.1:443' };
|
||||
|
||||
it('logs a worker connection once and drops pf flows to a peer the worker already holds', () => {
|
||||
const tracker = new LocalEgressInventoryTracker();
|
||||
expect(tracker.recordWorkerConnections([connection])).toHaveLength(1);
|
||||
expect(tracker.recordWorkerConnections([connection])).toHaveLength(0);
|
||||
tracker.recordPflogFlow(workerFlow, 1_000);
|
||||
expect(tracker.drainPflogFlows(1_000 + PFLOG_ATTRIBUTION_GRACE_MS)).toEqual([]);
|
||||
expect(tracker.workerPeerList()).toEqual(['TCP 34.120.1.1:443']);
|
||||
});
|
||||
|
||||
it('credits a flow to the worker when the sampler catches up within the grace period', () => {
|
||||
const tracker = new LocalEgressInventoryTracker();
|
||||
tracker.recordPflogFlow(workerFlow, 1_000);
|
||||
// Before the grace period nothing is decided.
|
||||
expect(tracker.drainPflogFlows(1_000 + PFLOG_ATTRIBUTION_GRACE_MS - 1)).toEqual([]);
|
||||
tracker.recordWorkerConnections([connection]);
|
||||
expect(tracker.drainPflogFlows(1_000 + PFLOG_ATTRIBUTION_GRACE_MS)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a flow the worker never held once the grace period passes, naming the process when the pid is known', () => {
|
||||
const tracker = new LocalEgressInventoryTracker();
|
||||
tracker.recordProcessNames(ps);
|
||||
const flow = { ...workerFlow, remote: '17.253.1.1:443', pid: 4001 };
|
||||
tracker.recordPflogFlow(flow, 1_000);
|
||||
tracker.recordPflogFlow(flow, 1_500);
|
||||
expect(tracker.drainPflogFlows(Infinity)).toEqual([
|
||||
{
|
||||
source: 'pf',
|
||||
protocol: 'TCP',
|
||||
remote: '17.253.1.1:443',
|
||||
command: 'SpringBoard',
|
||||
pid: 4001,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('stops logging past the limit and counts what it dropped', () => {
|
||||
const tracker = new LocalEgressInventoryTracker(1);
|
||||
tracker.recordPflogFlow({ ...workerFlow, remote: '1.1.1.1:443' }, 0);
|
||||
tracker.recordPflogFlow({ ...workerFlow, remote: '1.1.1.2:443' }, 0);
|
||||
expect(tracker.drainPflogFlows(Infinity)).toHaveLength(1);
|
||||
expect(tracker.suppressedCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
import { type DetachedProcessHandle, spawnDetached } from './remoteDeviceRunSession';
|
||||
@@ -344,6 +345,82 @@ export async function configureSystemProxyAsync({
|
||||
return { service };
|
||||
}
|
||||
|
||||
export const LOCAL_EGRESS_NO_PROXY = 'localhost,127.0.0.1,::1';
|
||||
|
||||
/**
|
||||
* Proxy environment for processes inside the simulator. CFNetwork ignores these
|
||||
* variables and follows the system proxy, but clients with their own network
|
||||
* stack that read them (gRPC and therefore Firestore, libcurl, Go) then send
|
||||
* HTTP CONNECT requests to the same loopback port. Loopback is excluded so the
|
||||
* ports forwarded for `--egress-allow` behave the same on both paths. Lowercase
|
||||
* and uppercase forms are both set because clients differ in which they read.
|
||||
*/
|
||||
export function buildLocalEgressSimulatorEnvironment(port: number): Record<string, string> {
|
||||
const proxyUrl = `http://${LOCAL_EGRESS_PROXY_HOST}:${port}`;
|
||||
return {
|
||||
http_proxy: proxyUrl,
|
||||
https_proxy: proxyUrl,
|
||||
HTTP_PROXY: proxyUrl,
|
||||
HTTPS_PROXY: proxyUrl,
|
||||
grpc_proxy: proxyUrl,
|
||||
no_proxy: LOCAL_EGRESS_NO_PROXY,
|
||||
NO_PROXY: LOCAL_EGRESS_NO_PROXY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the local egress proxy environment in a booted simulator when a local
|
||||
* egress session is active. Returns false when local egress is not configured
|
||||
* or the environment could not be set. Failure is a warning, not an error: the
|
||||
* session still works, but clients that read these variables then exit from
|
||||
* this worker instead of the egress client, which the warning spells out.
|
||||
*/
|
||||
export async function configureSimulatorProxyEnvironmentAsync({
|
||||
udid,
|
||||
env,
|
||||
logger,
|
||||
handoffPath = LOCAL_EGRESS_HANDOFF_PATH,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
handoffPath?: string;
|
||||
}): Promise<boolean> {
|
||||
let handoff: LocalEgressHandoff | null;
|
||||
try {
|
||||
handoff = await readLocalEgressHandoffAsync(handoffPath);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress: could not read the local egress handoff, so proxy environment variables were ' +
|
||||
'not set in the Simulator. Clients that read http_proxy/https_proxy (for example gRPC and ' +
|
||||
'libcurl) will bypass local egress and exit from this worker.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!handoff) {
|
||||
return false;
|
||||
}
|
||||
const variables = buildLocalEgressSimulatorEnvironment(handoff.port);
|
||||
try {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({ udid, env, variables });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress: could not set proxy environment variables in the Simulator. Clients that read ' +
|
||||
'http_proxy/https_proxy (for example gRPC and libcurl) will bypass local egress and exit from ' +
|
||||
'this worker. CFNetwork clients (WebKit, URLSession) still follow the system proxy.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
logger.info(
|
||||
`Local egress: proxy environment variables set in the Simulator (${Object.keys(variables).join(
|
||||
', '
|
||||
)}), so clients that read them, such as gRPC and libcurl, also exit from the egress client.`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function writeLocalEgressHandoffAsync(
|
||||
handoff: LocalEgressHandoff,
|
||||
handoffPath: string = LOCAL_EGRESS_HANDOFF_PATH
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import dns from 'node:dns/promises';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
|
||||
/**
|
||||
* Local egress inventory: a log-only pf anchor plus a sampler of the worker's
|
||||
* own connections, so the hosts the device host must keep reaching can be
|
||||
* measured before any traffic is blocked.
|
||||
*
|
||||
* Simulator processes and the worker share a uid, so pf alone cannot tell them
|
||||
* apart. The anchor logs every TCP and UDP flow the uid opens to a non-loopback
|
||||
* peer, and the sampler lists the peers the worker process tree holds, which is
|
||||
* the attribution pf cannot give. A logged flow whose peer the worker tree also
|
||||
* holds is the worker's own; anything else came from the simulator and would
|
||||
* fail once a block rule exists.
|
||||
*
|
||||
* Nothing here blocks. The anchor lives under `com.apple/`, the only wildcard
|
||||
* the stock macOS ruleset evaluates, and is flushed when the session ends.
|
||||
*/
|
||||
|
||||
export const LOCAL_EGRESS_PF_ANCHOR = 'com.apple/eas-local-egress';
|
||||
const INVENTORY_SAMPLE_INTERVAL_MS = 2_000;
|
||||
const INVENTORY_LOG_LIMIT = 300;
|
||||
/**
|
||||
* pflog reports a flow on its first packet, before the sampler can have seen
|
||||
* the socket. Flows wait this long before being called unattributed, so a
|
||||
* worker connection that lives a couple of seconds is still credited to it.
|
||||
*/
|
||||
export const PFLOG_ATTRIBUTION_GRACE_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Rules for the inventory anchor. `pass ... log (user)` logs the packet that
|
||||
* creates state, so one line per connection, with the owning uid and pid in the
|
||||
* pflog header. Loopback peers are the proxy and the forwarded ports, which are
|
||||
* the intended path, so they are excluded.
|
||||
*/
|
||||
export function buildInventoryAnchorRules({ uid }: { uid: number }): string {
|
||||
return [
|
||||
`pass out log (user) inet proto tcp from any to ! 127.0.0.0/8 user ${uid}`,
|
||||
`pass out log (user) inet proto udp from any to ! 127.0.0.0/8 user ${uid}`,
|
||||
`pass out log (user) inet6 proto tcp from any to ! ::1 user ${uid}`,
|
||||
`pass out log (user) inet6 proto udp from any to ! ::1 user ${uid}`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export type PflogFlow = {
|
||||
action: string;
|
||||
protocol: 'TCP' | 'UDP';
|
||||
/** Peer as `host:port`, IPv6 hosts in brackets. */
|
||||
remote: string;
|
||||
uid: number | null;
|
||||
pid: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse one line of `tcpdump -n -e -ttt -i pflog0`, e.g.
|
||||
* `00:00:01.000000 rule 0/0(match): pass out on en0: [uid 501, pid 1234] 10.0.0.5.54321 > 93.184.216.34.443: Flags [S], ...`
|
||||
* or `... 2606:4700::1.443: ...` for IPv6. Lines that are not flows return null.
|
||||
*/
|
||||
export function parsePflogLine(line: string): PflogFlow | null {
|
||||
const header = /rule \S+\(\w+\): (\w+) (?:in|out) on \S+: /.exec(line);
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
const rest = line.slice(header.index + header[0].length);
|
||||
const ids = /^\[uid (\d+)(?:, pid (\d+))?\]\s*/.exec(rest);
|
||||
const uid = ids ? Number(ids[1]) : null;
|
||||
const pid = ids?.[2] ? Number(ids[2]) : null;
|
||||
const packet = ids ? rest.slice(ids[0].length) : rest;
|
||||
const peers = /^(\S+) > (\S+):\s*(.*)$/.exec(packet);
|
||||
if (!peers) {
|
||||
return null;
|
||||
}
|
||||
const dst = peers[2];
|
||||
const lastDot = dst.lastIndexOf('.');
|
||||
if (lastDot === -1) {
|
||||
return null;
|
||||
}
|
||||
const host = dst.slice(0, lastDot);
|
||||
const port = dst.slice(lastDot + 1);
|
||||
if (!/^\d+$/.test(port)) {
|
||||
return null;
|
||||
}
|
||||
const tail = peers[3];
|
||||
const protocol: 'TCP' | 'UDP' = /\bUDP\b/.test(tail) ? 'UDP' : 'TCP';
|
||||
const remote = host.includes(':') ? `[${host}]:${port}` : `${host}:${port}`;
|
||||
return { action: header[1], protocol, remote, uid, pid };
|
||||
}
|
||||
|
||||
/**
|
||||
* `ps -axo pid=,ppid=,comm=` output to the pids of `rootPid` and every
|
||||
* descendant. The worker runs build steps in-process, so its own outbound
|
||||
* connections come from this tree; simulator processes hang off launchd_sim
|
||||
* instead and are never in it.
|
||||
*/
|
||||
export function collectDescendantProcessIds(psOutput: string, rootPid: number): number[] {
|
||||
const childrenByParent = new Map<number, number[]>();
|
||||
for (const line of psOutput.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+(\d+)\s+/.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const pid = Number(match[1]);
|
||||
const parentPid = Number(match[2]);
|
||||
const siblings = childrenByParent.get(parentPid) ?? [];
|
||||
siblings.push(pid);
|
||||
childrenByParent.set(parentPid, siblings);
|
||||
}
|
||||
const pids = [rootPid];
|
||||
const seen = new Set<number>(pids);
|
||||
for (let i = 0; i < pids.length; i++) {
|
||||
for (const child of childrenByParent.get(pids[i]) ?? []) {
|
||||
if (!seen.has(child)) {
|
||||
seen.add(child);
|
||||
pids.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return pids;
|
||||
}
|
||||
|
||||
export type WorkerConnection = {
|
||||
pid: number;
|
||||
command: string;
|
||||
protocol: string;
|
||||
remote: string;
|
||||
};
|
||||
|
||||
export type InventoryEntry = {
|
||||
source: 'worker' | 'pf';
|
||||
protocol: string;
|
||||
remote: string;
|
||||
/** Process that owns the connection, when known. */
|
||||
command: string | null;
|
||||
pid: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deduplicates what the sampler and the pflog reader see and decides what is
|
||||
* worth one log line. Pure, so the decision logic is testable without pf.
|
||||
*/
|
||||
export class LocalEgressInventoryTracker {
|
||||
private readonly seen = new Set<string>();
|
||||
private readonly workerPeers = new Set<string>();
|
||||
private readonly commandsByPid = new Map<number, string>();
|
||||
private readonly pendingFlows: { flow: PflogFlow; at: number }[] = [];
|
||||
private logged = 0;
|
||||
private suppressed = 0;
|
||||
|
||||
constructor(private readonly limit: number = INVENTORY_LOG_LIMIT) {}
|
||||
|
||||
recordProcessNames(psOutput: string): void {
|
||||
for (const line of psOutput.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+\d+\s+(\S.*)$/.exec(line);
|
||||
if (match) {
|
||||
this.commandsByPid.set(Number(match[1]), path.basename(match[2].trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recordWorkerConnections(connections: WorkerConnection[]): InventoryEntry[] {
|
||||
const entries: InventoryEntry[] = [];
|
||||
for (const connection of connections) {
|
||||
this.workerPeers.add(`${connection.protocol} ${connection.remote}`);
|
||||
const entry: InventoryEntry = {
|
||||
source: 'worker',
|
||||
protocol: connection.protocol,
|
||||
remote: connection.remote,
|
||||
command: connection.command,
|
||||
pid: connection.pid,
|
||||
};
|
||||
if (this.admit(`worker ${connection.protocol} ${connection.remote}`)) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Queue a pflog flow; `drainPflogFlows` decides on it after the grace period. */
|
||||
recordPflogFlow(flow: PflogFlow, at: number = Date.now()): void {
|
||||
this.pendingFlows.push({ flow, at });
|
||||
}
|
||||
|
||||
/**
|
||||
* Flows older than the grace period that the sampler has not credited to
|
||||
* the worker by now. Pass `Infinity` to decide on everything, at shutdown.
|
||||
*/
|
||||
drainPflogFlows(
|
||||
now: number = Date.now(),
|
||||
graceMs: number = PFLOG_ATTRIBUTION_GRACE_MS
|
||||
): InventoryEntry[] {
|
||||
const entries: InventoryEntry[] = [];
|
||||
while (this.pendingFlows.length > 0 && now - this.pendingFlows[0].at >= graceMs) {
|
||||
const { flow } = this.pendingFlows.shift()!;
|
||||
const key = `${flow.protocol} ${flow.remote}`;
|
||||
if (this.workerPeers.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const command = flow.pid === null ? null : (this.commandsByPid.get(flow.pid) ?? null);
|
||||
if (this.admit(`pf ${key}`)) {
|
||||
entries.push({
|
||||
source: 'pf',
|
||||
protocol: flow.protocol,
|
||||
remote: flow.remote,
|
||||
command,
|
||||
pid: flow.pid,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Distinct peers the worker tree held, for the closing summary. */
|
||||
workerPeerList(): string[] {
|
||||
return [...this.workerPeers].sort();
|
||||
}
|
||||
|
||||
suppressedCount(): number {
|
||||
return this.suppressed;
|
||||
}
|
||||
|
||||
private admit(key: string): boolean {
|
||||
if (this.seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
this.seen.add(key);
|
||||
if (this.logged >= this.limit) {
|
||||
this.suppressed++;
|
||||
return false;
|
||||
}
|
||||
this.logged++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function remoteHostOf(remote: string): string {
|
||||
if (remote.startsWith('[')) {
|
||||
const end = remote.indexOf(']');
|
||||
return end === -1 ? remote : remote.slice(1, end);
|
||||
}
|
||||
const colon = remote.lastIndexOf(':');
|
||||
return colon === -1 ? remote : remote.slice(0, colon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `lsof -nP -i -F pcPnT` for the given pids: connected TCP and UDP
|
||||
* sockets to peers outside loopback.
|
||||
*/
|
||||
export function parseWorkerConnections(
|
||||
lsofOutput: string,
|
||||
pids: ReadonlySet<number>
|
||||
): WorkerConnection[] {
|
||||
const connections: WorkerConnection[] = [];
|
||||
let pid: number | null = null;
|
||||
let command = '';
|
||||
let protocol: string | null = null;
|
||||
let name: string | null = null;
|
||||
let tcpState: string | null = null;
|
||||
const flush = (): void => {
|
||||
if (pid !== null && pids.has(pid) && protocol && name) {
|
||||
const arrow = name.indexOf('->');
|
||||
const remote = arrow === -1 ? null : name.slice(arrow + 2);
|
||||
const active =
|
||||
protocol !== 'TCP' ||
|
||||
tcpState === null ||
|
||||
tcpState === 'ESTABLISHED' ||
|
||||
tcpState === 'SYN_SENT';
|
||||
const host = remote ? remoteHostOf(remote) : '';
|
||||
if (remote && active && !host.startsWith('127.') && host !== '::1' && host !== 'localhost') {
|
||||
connections.push({ pid, command, protocol, remote });
|
||||
}
|
||||
}
|
||||
protocol = null;
|
||||
name = null;
|
||||
tcpState = null;
|
||||
};
|
||||
for (const line of lsofOutput.split('\n')) {
|
||||
const field = line[0];
|
||||
const value = line.slice(1);
|
||||
if (field === 'p') {
|
||||
flush();
|
||||
pid = Number(value);
|
||||
command = '';
|
||||
} else if (field === 'c') {
|
||||
command = value;
|
||||
} else if (field === 'f') {
|
||||
flush();
|
||||
} else if (field === 'P') {
|
||||
protocol = value;
|
||||
} else if (field === 'n') {
|
||||
name = value;
|
||||
} else if (field === 'T' && value.startsWith('ST=')) {
|
||||
tcpState = value.slice('ST='.length);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return connections;
|
||||
}
|
||||
|
||||
const reverseLookups = new Map<string, Promise<string | null>>();
|
||||
|
||||
async function reverseLookupAsync(host: string): Promise<string | null> {
|
||||
let pending = reverseLookups.get(host);
|
||||
if (!pending) {
|
||||
pending = dns
|
||||
.reverse(host)
|
||||
.then(names => names[0] ?? null)
|
||||
.catch(() => null);
|
||||
reverseLookups.set(host, pending);
|
||||
}
|
||||
return await pending;
|
||||
}
|
||||
|
||||
async function describeEntryAsync(entry: InventoryEntry): Promise<string> {
|
||||
const name = await reverseLookupAsync(remoteHostOf(entry.remote));
|
||||
const peer = name ? `${entry.remote} (${name})` : entry.remote;
|
||||
const owner =
|
||||
entry.command && entry.pid !== null
|
||||
? `${entry.command} (pid ${entry.pid})`
|
||||
: entry.pid !== null
|
||||
? `pid ${entry.pid}`
|
||||
: 'an unattributed process';
|
||||
return entry.source === 'worker'
|
||||
? `Local egress inventory: worker process ${owner} holds a ${entry.protocol} connection to ${peer}.`
|
||||
: `Local egress inventory: pf logged a ${entry.protocol} flow to ${peer} from ${owner}; not held by the worker process tree, so it is likely a simulator process bypassing the proxy.`;
|
||||
}
|
||||
|
||||
async function runSudoAsync(args: string[], env: BuildStepEnv): Promise<string> {
|
||||
const result = await spawn('sudo', ['-n', ...args], { env, stdio: 'pipe' });
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export type LocalEgressInventoryHandle = {
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load the log-only anchor, start reading pflog0 and sampling the worker's
|
||||
* own connections. Logs each distinct destination once. The returned handle
|
||||
* flushes the anchor and stops both readers; the caller runs it from the
|
||||
* session cleanup. Any failure here is the caller's to log as a warning: the
|
||||
* inventory is observation only and must never fail the session.
|
||||
*/
|
||||
export async function startLocalEgressInventoryAsync({
|
||||
env,
|
||||
logger,
|
||||
uid = process.getuid?.() ?? -1,
|
||||
rootPid = process.pid,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
uid?: number;
|
||||
rootPid?: number;
|
||||
}): Promise<LocalEgressInventoryHandle> {
|
||||
if (uid < 0) {
|
||||
throw new Error('Could not determine the worker uid for the local egress inventory.');
|
||||
}
|
||||
const tracker = new LocalEgressInventoryTracker();
|
||||
const rulesPath = path.join(os.tmpdir(), `eas-local-egress-inventory-${process.pid}.conf`);
|
||||
await fs.promises.writeFile(rulesPath, buildInventoryAnchorRules({ uid }), 'utf8');
|
||||
await runSudoAsync(['pfctl', '-q', '-a', LOCAL_EGRESS_PF_ANCHOR, '-f', rulesPath], env);
|
||||
let enabledPf = false;
|
||||
try {
|
||||
await runSudoAsync(['pfctl', '-q', '-E'], env);
|
||||
enabledPf = true;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress inventory: could not enable pf; the anchor is loaded but idle.'
|
||||
);
|
||||
}
|
||||
try {
|
||||
await runSudoAsync(['ifconfig', 'pflog0', 'create'], env);
|
||||
} catch {
|
||||
// Already exists.
|
||||
}
|
||||
|
||||
const tcpdump = spawn('sudo', ['-n', 'tcpdump', '-l', '-n', '-e', '-ttt', '-i', 'pflog0'], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
tcpdump.catch(() => {});
|
||||
let stopped = false;
|
||||
const logEntryAsync = async (entry: InventoryEntry): Promise<void> => {
|
||||
logger.info(await describeEntryAsync(entry));
|
||||
};
|
||||
if (tcpdump.child.stdout) {
|
||||
const lines = readline.createInterface({ input: tcpdump.child.stdout });
|
||||
lines.on('line', line => {
|
||||
const flow = parsePflogLine(line);
|
||||
if (flow && !stopped) {
|
||||
tracker.recordPflogFlow(flow);
|
||||
}
|
||||
});
|
||||
}
|
||||
const drainAsync = async (now?: number): Promise<void> => {
|
||||
for (const entry of tracker.drainPflogFlows(now)) {
|
||||
await logEntryAsync(entry);
|
||||
}
|
||||
};
|
||||
|
||||
const sampleAsync = async (): Promise<void> => {
|
||||
const ps = await spawn('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' });
|
||||
tracker.recordProcessNames(ps.stdout);
|
||||
const pids = collectDescendantProcessIds(ps.stdout, rootPid);
|
||||
let lsofOutput: string;
|
||||
try {
|
||||
const lsof = await spawn('lsof', ['-nP', '-i', '-F', 'pcPnT', '-a', '-p', pids.join(',')], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
lsofOutput = lsof.stdout;
|
||||
} catch (err) {
|
||||
// lsof exits 1 when no listed process holds a socket; stdout is still valid.
|
||||
const result = err as { status?: number | null; stdout?: string };
|
||||
if (result.status !== 1) {
|
||||
throw err;
|
||||
}
|
||||
lsofOutput = result.stdout ?? '';
|
||||
}
|
||||
for (const entry of tracker.recordWorkerConnections(
|
||||
parseWorkerConnections(lsofOutput, new Set(pids))
|
||||
)) {
|
||||
await logEntryAsync(entry);
|
||||
}
|
||||
};
|
||||
// Sample once right away so flows logged during boot have a baseline, then
|
||||
// keep sampling; decide on queued pflog flows after each sample.
|
||||
let sampling: Promise<void> = sampleAsync().catch(err => {
|
||||
logger.debug({ err }, 'Local egress inventory: sampling the worker process tree failed.');
|
||||
});
|
||||
const timer = setInterval(() => {
|
||||
sampling = sampling
|
||||
.then(sampleAsync)
|
||||
.catch(err => {
|
||||
logger.debug({ err }, 'Local egress inventory: sampling the worker process tree failed.');
|
||||
})
|
||||
.then(() => drainAsync());
|
||||
}, INVENTORY_SAMPLE_INTERVAL_MS);
|
||||
timer.unref();
|
||||
|
||||
logger.info(
|
||||
`Local egress inventory: logging every non-loopback TCP and UDP flow from uid ${uid} through pf anchor ${LOCAL_EGRESS_PF_ANCHOR} (log only, nothing is blocked) and sampling the worker's own connections every ${INVENTORY_SAMPLE_INTERVAL_MS / 1000}s.`
|
||||
);
|
||||
|
||||
return {
|
||||
stopAsync: async () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
await sampling.catch(() => {});
|
||||
// One last sample, then decide on everything still queued.
|
||||
await sampleAsync().catch(() => {});
|
||||
await drainAsync(Infinity);
|
||||
if (tcpdump.child.pid !== undefined) {
|
||||
await runSudoAsync(['kill', String(tcpdump.child.pid)], env).catch(() => {});
|
||||
}
|
||||
await runSudoAsync(['pfctl', '-q', '-a', LOCAL_EGRESS_PF_ANCHOR, '-F', 'all'], env).catch(
|
||||
err => {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Local egress inventory: could not flush pf anchor ${LOCAL_EGRESS_PF_ANCHOR}.`
|
||||
);
|
||||
}
|
||||
);
|
||||
if (enabledPf) {
|
||||
await runSudoAsync(['pfctl', '-q', '-X'], env).catch(() => {});
|
||||
}
|
||||
await fs.promises.rm(rulesPath, { force: true });
|
||||
const peers = tracker.workerPeerList();
|
||||
logger.info(
|
||||
`Local egress inventory: the worker process tree held connections to ${peers.length} distinct peer(s)${
|
||||
peers.length ? `: ${peers.join(', ')}` : ''
|
||||
}.${tracker.suppressedCount() ? ` ${tracker.suppressedCount()} further distinct destinations were not logged.` : ''}`
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -285,6 +285,28 @@ export namespace IosSimulatorUtils {
|
||||
throw lastError ?? new SystemError('Unable to disable apsd in the Simulator.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set environment variables in the Simulator's launchd. Every process that
|
||||
* launchd spawns afterwards inherits them: apps launched by SpringBoard
|
||||
* (deep links, taps, WebDriverAgent) as well as by `simctl launch`.
|
||||
* Processes that are already running keep their environment.
|
||||
*/
|
||||
export async function setLaunchdEnvironmentAsync({
|
||||
udid,
|
||||
env,
|
||||
variables,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
variables: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
for (const [name, value] of Object.entries(variables)) {
|
||||
await spawn('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'setenv', name, value], {
|
||||
env,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectLogsAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
|
||||
@@ -340,4 +340,54 @@ describe('IosSimulatorUtils', () => {
|
||||
).rejects.toThrow(SystemError);
|
||||
});
|
||||
});
|
||||
describe(IosSimulatorUtils.setLaunchdEnvironmentAsync, () => {
|
||||
it('sets each variable in the simulator launchd, in order', async () => {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
variables: { https_proxy: 'http://127.0.0.1:8899', no_proxy: 'localhost,127.0.0.1' },
|
||||
});
|
||||
|
||||
expect(mockedSpawn.mock.calls).toEqual([
|
||||
[
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'test-udid',
|
||||
'launchctl',
|
||||
'setenv',
|
||||
'https_proxy',
|
||||
'http://127.0.0.1:8899',
|
||||
],
|
||||
{ env: process.env },
|
||||
],
|
||||
[
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'test-udid',
|
||||
'launchctl',
|
||||
'setenv',
|
||||
'no_proxy',
|
||||
'localhost,127.0.0.1',
|
||||
],
|
||||
{ env: process.env },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('propagates a launchctl failure', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('launchctl failed'));
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
variables: { https_proxy: 'http://127.0.0.1:8899' },
|
||||
})
|
||||
).rejects.toThrow('launchctl failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -139,7 +139,7 @@ export default class Simulator extends EasCommand {
|
||||
})(),
|
||||
egress: Flags.option({
|
||||
description:
|
||||
'With "local", the simulator system proxy points at this machine: HTTP(S) and WebSocket requests that honor it (WebKit, URLSession) exit from this machine and fail while the egress client is disconnected. Requests from libraries that bypass the system proxy are not covered. The egress client must keep running for the life of the session. Only supported with --platform ios.',
|
||||
'With "local", the simulator system proxy points at this machine: HTTP(S) and WebSocket requests that honor it (WebKit, URLSession) and clients that read proxy environment variables (gRPC, libcurl) exit from this machine and fail while the egress client is disconnected. Requests from libraries that ignore both are not covered. The egress client must keep running for the life of the session. Only supported with --platform ios.',
|
||||
options: EGRESS_FLAG_VALUES,
|
||||
})(),
|
||||
'egress-allow': Flags.string({
|
||||
|
||||
Reference in new issue
Block a user