Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/components/intercept/config/emudhra-config.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as React from 'react';
import { observable, flow } from 'mobx';
import { inject, observer } from 'mobx-react';

import { ProxyStore } from '../../../model/proxy-store';
import { Interceptor } from '../../../model/interception/interceptors';
import { asError } from '../../../util/error';
import { Content, CopyableMonoValue } from '../../common/text-content';
import { Button } from '../../common/inputs';

@inject('proxyStore')
@observer
export class EmudhraConfig extends React.Component<{
proxyStore?: ProxyStore;
interceptor: Interceptor;
closeSelf: () => void;
activateInterceptor: () => Promise<{ port: number }>;
reportStarted: () => void;
reportSuccess: (options?: { showRequests?: boolean }) => void;
}> {
@observable port: number | undefined;
@observable error: string | undefined;

componentDidMount() {
this.enable();
}

enable = flow(function * (this: EmudhraConfig) {
this.error = undefined;
this.props.reportStarted();
try {
this.port = yield this.props.proxyStore!.enableExtraHttps('emudhra');
this.props.reportSuccess({ showRequests: false });
} catch (error) {
this.error = asError(error).message;
}
}).bind(this);

render() {
if (this.error) return <Content>
<p role="alert">Could not enable eMudhra interception: {this.error}</p>
<Button onClick={this.enable}>Try again</Button>
</Content>;

if (!this.port) return <p>Opening the eMudhra interception port…</p>;

return <Content style={{ overflowY: 'auto' }}>
<p>
Configure your client's HTTP/HTTPS proxy using one of these addresses.
From another device, use this machine's network IP address.
</p>
<ul>
{['localhost', ...this.props.proxyStore!.externalNetworkAddresses].map(address => <li key={address}>
<CopyableMonoValue>http://{address.includes(':') ? `[${address}]` : address}:{this.port}</CopyableMonoValue>
</li>)}
</ul>
<p>
For devices without proxy settings, route their traffic through this machine
and forward HTTP/TLS connections to this port, preserving the original hostname.
This may require DNS overrides, ARP spoofing, or a custom Wi-Fi hotspot with port forwarding.
</p>
<p>
Uses the compromised eMudhra CA signed by AAA Certificate Services.
The client must already trust this CA. Do not install additional certificates.
</p>
<p>
Your normal proxy keeps using the local CA. This extra port stays open until
the proxy session ends.
</p>
</Content>;
}
}

export const EmudhraCustomUi = {
rowHeight: 1,
columnWidth: 4,
configComponent: EmudhraConfig
};
3 changes: 2 additions & 1 deletion src/model/interception/interceptor-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export class InterceptorStore {
const supportedInterceptors = getInterceptOptions(
serverInterceptors,
this.accountStore,
serverVersion
serverVersion,
this.proxyStore.extraHttpsNames
);

// Quick patch for a bug in existing-chrome for server <= 1.1.2 which incorrectly
Expand Down
20 changes: 17 additions & 3 deletions src/model/interception/interceptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { onActivateExistingBrowser } from "../../components/intercept/config/exi
import { JvmCustomUi } from "../../components/intercept/config/jvm-config";
import { DockerAttachCustomUi } from "../../components/intercept/config/docker-attach-config";
import { ManualIOSCustomUi } from "../../components/intercept/config/manual-ios-config";
import { EmudhraCustomUi } from '../../components/intercept/config/emudhra-config';

interface InterceptorConfig {
name: string;
Expand All @@ -31,7 +32,8 @@ interface InterceptorConfig {
checkRequirements?: (options: {
interceptorVersion: string,
accountStore: AccountStore,
serverVersion?: string
serverVersion?: string,
extraHttpsNames: string[]
}) => boolean;
uiConfig?: InterceptorCustomUiConfig;
customActivation?: CustomActivationFunction,
Expand Down Expand Up @@ -355,6 +357,16 @@ const INTERCEPT_OPTIONS: _.Dictionary<InterceptorConfig> = {
uiConfig: ElectronCustomUi,
tags: ['electron', 'desktop', 'postman']
},
'emudhra': {
name: 'eMudhra Interception',
description: ['Intercept clients that already trust the legacy compromised eMudhra certificates'],
iconProps: { icon: ['fas', 'unlock'], color: '#d98b32' },
uiConfig: EmudhraCustomUi,
clientOnly: true,
checkRequirements: ({ extraHttpsNames }) => extraHttpsNames.includes('emudhra'),
tags: ['emudhra', 'legacy', 'iot', 'network', 'devices'],
experimental: true
},
[MANUAL_INTERCEPT_ID]: {
name: 'Anything',
description: ["Manually configure any source using the proxy settings and CA certificate"],
Expand All @@ -368,7 +380,8 @@ const INTERCEPT_OPTIONS: _.Dictionary<InterceptorConfig> = {
export function getInterceptOptions(
serverInterceptorArray: ServerInterceptor[],
accountStore: AccountStore,
serverVersion?: string
serverVersion?: string,
extraHttpsNames: string[] = []
) {
const serverInterceptors = _.keyBy(serverInterceptorArray, 'id');

Expand All @@ -381,7 +394,8 @@ export function getInterceptOptions(
(option.checkRequirements && !option.checkRequirements({
interceptorVersion: (serverInterceptors[id] || {}).version,
accountStore,
serverVersion
serverVersion,
extraHttpsNames
}))
) {
// The UI knows about this interceptor, but we can't use it for some reason.
Expand Down
9 changes: 9 additions & 0 deletions src/model/proxy-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ export class ProxyStore {
{ messageBodyDecoding: 'none' }
);

this.extraHttpsNames = this.adminClient.metadata.http.extraHttpsNames ?? [];

announceServerReady();
console.log('Server started');

Expand Down Expand Up @@ -313,6 +315,13 @@ export class ProxyStore {
return this.adminClient.metadata.http.port;
}

@observable
extraHttpsNames: string[] = [];

enableExtraHttps(name: string): Promise<number> {
return this.adminClient.sendQuery(this.mockttpRequestBuilder.buildEnableExtraHttpsQuery(name));
}

@persist @observable
http2Enabled: true | false | 'fallback' = 'fallback';
private _http2CurrentlyEnabled = this.http2Enabled;
Expand Down
60 changes: 60 additions & 0 deletions test/unit/components/intercept/emudhra-config.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as React from 'react';
import { shallow } from 'enzyme';
import { expect } from '../../../test-setup';
import { EmudhraConfig } from '../../../../src/components/intercept/config/emudhra-config';
import { CopyableMonoValue } from '../../../../src/components/common/text-content';
import { ProxyStore } from '../../../../src/model/proxy-store';

const Config = (EmudhraConfig as any).wrappedComponent;

describe('eMudhra interception instructions', () => {
const props = () => ({
interceptor: {} as any,
proxyStore: {
externalNetworkAddresses: ['192.0.2.1', '::1'],
enableExtraHttps: async (_name: string) => 8001
} as ProxyStore,
activateInterceptor: async () => { throw new Error('Unexpected server interceptor activation'); },
reportStarted: () => {},
reportSuccess: (_options?: { showRequests?: boolean }) => {},
closeSelf: () => {}
});

it('shows the allocated port and manual instructions after activation', async () => {
const config = props();
let started = false;
let showRequests: boolean | undefined;
config.reportStarted = () => { started = true; };
config.reportSuccess = options => { showRequests = options?.showRequests; };
const view = shallow(<Config {...config} />, { disableLifecycleMethods: true });
expect(view.text()).to.contain('Opening');
await (view.instance() as any).enable();
view.update();
expect(started).to.equal(true);
expect(showRequests).to.equal(false);
const addresses = view.find(CopyableMonoValue).map(node => node.children().map(child => child.text()).join(''));
expect(addresses).to.deep.equal([
'http://localhost:8001', 'http://192.0.2.1:8001', 'http://[::1]:8001'
]);
expect(view.find('p').map(node => node.text()).join(' ')).to.contain('Do not install additional certificates');
view.unmount();
});

it('shows server errors and allows activation to be retried', async () => {
const config = props();
let attempts = 0;
config.proxyStore.enableExtraHttps = async () => {
if (++attempts === 1) throw new Error('No free port available');
return 8002;
};
const view = shallow(<Config {...config} />, { disableLifecycleMethods: true });
await (view.instance() as any).enable();
view.update();
expect(view.find({ role: 'alert' }).text()).to.contain('No free port available');
await (view.instance() as any).enable();
view.update();
expect(view.find({ role: 'alert' }).exists()).to.equal(false);
expect(view.find(CopyableMonoValue).first().children().map(child => child.text()).join('')).to.equal('http://localhost:8002');
view.unmount();
});
});