diff --git a/src/components/intercept/config/emudhra-config.tsx b/src/components/intercept/config/emudhra-config.tsx
new file mode 100644
index 000000000..325726458
--- /dev/null
+++ b/src/components/intercept/config/emudhra-config.tsx
@@ -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
+ Could not enable eMudhra interception: {this.error}
+ Try again
+ ;
+
+ if (!this.port) return
Opening the eMudhra interception port…
;
+
+ return
+
+ Configure your client's HTTP/HTTPS proxy using one of these addresses.
+ From another device, use this machine's network IP address.
+
+
+ {['localhost', ...this.props.proxyStore!.externalNetworkAddresses].map(address =>
+ http://{address.includes(':') ? `[${address}]` : address}:{this.port}
+ )}
+
+
+ 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.
+
+
+ Uses the compromised eMudhra CA signed by AAA Certificate Services.
+ The client must already trust this CA. Do not install additional certificates.
+
+
+ Your normal proxy keeps using the local CA. This extra port stays open until
+ the proxy session ends.
+
+ ;
+ }
+}
+
+export const EmudhraCustomUi = {
+ rowHeight: 1,
+ columnWidth: 4,
+ configComponent: EmudhraConfig
+};
diff --git a/src/model/interception/interceptor-store.ts b/src/model/interception/interceptor-store.ts
index e09c875aa..deab02009 100644
--- a/src/model/interception/interceptor-store.ts
+++ b/src/model/interception/interceptor-store.ts
@@ -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
diff --git a/src/model/interception/interceptors.ts b/src/model/interception/interceptors.ts
index 92f66f241..c746da216 100644
--- a/src/model/interception/interceptors.ts
+++ b/src/model/interception/interceptors.ts
@@ -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;
@@ -31,7 +32,8 @@ interface InterceptorConfig {
checkRequirements?: (options: {
interceptorVersion: string,
accountStore: AccountStore,
- serverVersion?: string
+ serverVersion?: string,
+ extraHttpsNames: string[]
}) => boolean;
uiConfig?: InterceptorCustomUiConfig;
customActivation?: CustomActivationFunction,
@@ -355,6 +357,16 @@ const INTERCEPT_OPTIONS: _.Dictionary = {
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"],
@@ -368,7 +380,8 @@ const INTERCEPT_OPTIONS: _.Dictionary = {
export function getInterceptOptions(
serverInterceptorArray: ServerInterceptor[],
accountStore: AccountStore,
- serverVersion?: string
+ serverVersion?: string,
+ extraHttpsNames: string[] = []
) {
const serverInterceptors = _.keyBy(serverInterceptorArray, 'id');
@@ -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.
diff --git a/src/model/proxy-store.ts b/src/model/proxy-store.ts
index d8f01998c..91aa60fcf 100644
--- a/src/model/proxy-store.ts
+++ b/src/model/proxy-store.ts
@@ -232,6 +232,8 @@ export class ProxyStore {
{ messageBodyDecoding: 'none' }
);
+ this.extraHttpsNames = this.adminClient.metadata.http.extraHttpsNames ?? [];
+
announceServerReady();
console.log('Server started');
@@ -313,6 +315,13 @@ export class ProxyStore {
return this.adminClient.metadata.http.port;
}
+ @observable
+ extraHttpsNames: string[] = [];
+
+ enableExtraHttps(name: string): Promise {
+ return this.adminClient.sendQuery(this.mockttpRequestBuilder.buildEnableExtraHttpsQuery(name));
+ }
+
@persist @observable
http2Enabled: true | false | 'fallback' = 'fallback';
private _http2CurrentlyEnabled = this.http2Enabled;
diff --git a/test/unit/components/intercept/emudhra-config.spec.tsx b/test/unit/components/intercept/emudhra-config.spec.tsx
new file mode 100644
index 000000000..114f32003
--- /dev/null
+++ b/test/unit/components/intercept/emudhra-config.spec.tsx
@@ -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( , { 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( , { 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();
+ });
+});