summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2018-07-03 13:39:18 +0200
committerAndrej Mihajlov <and@mullvad.net>2018-07-03 13:39:18 +0200
commit38968509b59329f2d0743312fc8f9a4f98daa66c (patch)
treef6e5f8b4f8dc72ae1bae0ddfe715b8094deb3750
parentbe096ee87bb1256b67c3b24b61be77d523aff9cc (diff)
parentb3a6f58d7027e5961180b52d6473f7f504638284 (diff)
downloadmullvadvpn-38968509b59329f2d0743312fc8f9a4f98daa66c.tar.xz
mullvadvpn-38968509b59329f2d0743312fc8f9a4f98daa66c.zip
Merge branch 'app-overhaul'
-rw-r--r--app/app.android.js4
-rw-r--r--app/app.js50
-rw-r--r--app/components/Account.js2
-rw-r--r--app/components/Login.js113
-rw-r--r--app/components/SelectLocation.js2
-rw-r--r--app/containers/ConnectPage.js24
-rw-r--r--app/containers/LoginPage.js26
-rw-r--r--app/containers/SelectLocationPage.js2
-rw-r--r--app/containers/SettingsPage.js5
-rw-r--r--app/lib/backend.js603
-rw-r--r--app/lib/daemon-rpc.js387
-rw-r--r--app/lib/ipc-facade.js356
-rw-r--r--app/lib/jsonrpc-transport.js326
-rw-r--r--app/lib/jsonrpc-ws-ipc.js312
-rw-r--r--app/lib/platform.android.js7
-rw-r--r--app/lib/platform.js6
-rw-r--r--app/lib/relay-settings-builder.js2
-rw-r--r--app/lib/rpc-address-file.js115
-rw-r--r--app/lib/rpc-file-security.js36
-rw-r--r--app/main.js170
-rw-r--r--app/redux/account/actions.js2
-rw-r--r--app/redux/account/reducers.js2
-rw-r--r--app/redux/connection/actions.js8
-rw-r--r--app/redux/connection/reducers.js2
-rw-r--r--app/redux/settings/reducers.js2
-rw-r--r--package.json3
-rw-r--r--test/auth.spec.js37
-rw-r--r--test/autologin.spec.js126
-rw-r--r--test/components/Login.spec.js104
-rw-r--r--test/connect.spec.js62
-rw-r--r--test/helpers/IpcChain.js100
-rw-r--r--test/helpers/ipc-helpers.js104
-rw-r--r--test/ipc.spec.js134
-rw-r--r--test/jsonrpc-transport.spec.js144
-rw-r--r--test/login.spec.js83
-rw-r--r--test/logout.spec.js67
-rw-r--r--test/mocks/redux.js4
-rw-r--r--test/mocks/rpc.js (renamed from test/mocks/ipc.js)75
-rw-r--r--yarn.lock22
39 files changed, 1578 insertions, 2051 deletions
diff --git a/app/app.android.js b/app/app.android.js
index d362f25283..e16c3cc6a6 100644
--- a/app/app.android.js
+++ b/app/app.android.js
@@ -22,14 +22,14 @@ const store = configureStore(initialState, memoryHistory);
//////////////////////////////////////////////////////////////////////////
const backend = new Backend(store);
-DeviceEventEmitter.addListener('com.mullvad.backend-info', async (_event, args) => {
+DeviceEventEmitter.addListener('com.mullvad.daemon-connection-ready', async (_event, args) => {
backend.setCredentials(args.credentials);
backend.sync();
try {
await backend.autologin();
await backend.fetchRelaySettings();
await backend.fetchSecurityState();
- await backend.connect();
+ await backend.connectTunnel();
} catch (e) {
if (e instanceof NoAccountError) {
log.debug('No previously configured account set, showing window');
diff --git a/app/app.js b/app/app.js
index dadc2177c2..d2eacfe751 100644
--- a/app/app.js
+++ b/app/app.js
@@ -9,55 +9,45 @@ import { webFrame, ipcRenderer } from 'electron';
import { log } from './lib/platform';
import makeRoutes from './routes';
import configureStore from './redux/store';
-import { Backend, NoAccountError } from './lib/backend';
-
+import { Backend } from './lib/backend';
+import { DaemonRpc } from './lib/daemon-rpc';
import { setShutdownHandler } from './shutdown-handler';
import type { ConnectionState } from './redux/connection/reducers';
import type { TrayIconType } from './tray-icon-controller';
+import type { RpcCredentialsProvider, RpcCredentials } from './lib/backend';
const initialState = null;
const memoryHistory = createMemoryHistory();
const store = configureStore(initialState, memoryHistory);
-//////////////////////////////////////////////////////////////////////////
-// Backend
-//////////////////////////////////////////////////////////////////////////
-const backend = new Backend(store);
-ipcRenderer.on('backend-info', async (_event, args) => {
- backend.setCredentials(args.credentials);
- backend.sync();
- try {
- await backend.autologin();
- await backend.fetchRelaySettings();
- await backend.fetchSecurityState();
- await backend.connect();
- } catch (e) {
- if (e instanceof NoAccountError) {
- log.debug('No previously configured account set, showing window');
- ipcRenderer.send('show-window');
- }
+class CredentialsProvider implements RpcCredentialsProvider {
+ request(): Promise<RpcCredentials> {
+ return new Promise((resolve, _reject) => {
+ ipcRenderer.once('daemon-connection-ready', (_event, credentials: RpcCredentials) => {
+ resolve(credentials);
+ });
+ ipcRenderer.send('discover-daemon-connection');
+ });
}
-});
+}
+
+const rpc = new DaemonRpc();
+const credentialsProvider = new CredentialsProvider();
+const backend = new Backend(store, rpc, credentialsProvider);
+backend.connect();
setShutdownHandler(async () => {
log.info('Executing a shutdown handler');
try {
- await backend.disconnect();
+ await backend.disconnectTunnel();
log.info('Disconnected the tunnel');
} catch (e) {
log.error(`Failed to shutdown tunnel: ${e.message}`);
}
});
-//////////////////////////////////////////////////////////////////////////
-//////////////////////////////////////////////////////////////////////////
-
-//////////////////////////////////////////////////////////////////////////
-// Tray icon
-//////////////////////////////////////////////////////////////////////////
-
/**
* Get tray icon type based on connection state
*/
@@ -86,14 +76,10 @@ store.subscribe(updateTrayIcon);
// force update tray
updateTrayIcon();
-//////////////////////////////////////////////////////////////////////////
-//////////////////////////////////////////////////////////////////////////
// disable smart pinch.
webFrame.setVisualZoomLevelLimits(1, 1);
-ipcRenderer.send('on-browser-window-ready');
-
export default class App extends Component {
render() {
return (
diff --git a/app/components/Account.js b/app/components/Account.js
index fc72ff5b30..f8ea3dc19b 100644
--- a/app/components/Account.js
+++ b/app/components/Account.js
@@ -8,7 +8,7 @@ import styles from './AccountStyles';
import Img from './Img';
import { formatAccount } from '../lib/formatters';
-import type { AccountToken } from '../lib/ipc-facade';
+import type { AccountToken } from '../lib/daemon-rpc';
export type AccountProps = {
accountToken: AccountToken,
diff --git a/app/components/Login.js b/app/components/Login.js
index dd297c8e9c..e70d84b7aa 100644
--- a/app/components/Login.js
+++ b/app/components/Login.js
@@ -10,17 +10,20 @@ import { BlueButton, Label, CellButton } from './styled';
import styles from './LoginStyles';
import { colors } from '../config';
-import type { AccountReduxState } from '../redux/account/reducers';
-import type { AccountToken } from '../lib/ipc-facade';
+import type { LoginState } from '../redux/account/reducers';
+import type { AccountToken } from '../lib/daemon-rpc';
export type Props = {
- account: AccountReduxState,
- onLogin: (accountToken: AccountToken) => void,
- onSettings: ?() => void,
- onFirstChangeAfterFailure: () => void,
- onExternalLink: (type: string) => void,
- onAccountTokenChange: (accountToken: AccountToken) => void,
- onRemoveAccountTokenFromHistory: (accountToken: AccountToken) => void,
+ accountToken: ?AccountToken,
+ accountHistory: Array<AccountToken>,
+ loginError: ?Error,
+ loginState: LoginState,
+ openSettings: ?() => void,
+ openExternalLink: (type: string) => void,
+ login: (accountToken: AccountToken) => void,
+ resetLoginError: () => void,
+ updateAccountToken: (accountToken: AccountToken) => void,
+ removeAccountTokenFromHistory: (accountToken: AccountToken) => Promise<void>,
};
type State = {
@@ -33,7 +36,7 @@ export default class Login extends Component<Props, State> {
};
_accountInput: ?AccountInput;
- _notifyOnFirstChangeAfterFailure = false;
+ _shouldResetLoginError = false;
_showsFooter = true;
_footerAnimatedValue = Animated.createValue(0);
@@ -49,8 +52,8 @@ export default class Login extends Component<Props, State> {
constructor(props: Props) {
super(props);
- if (props.account.status === 'failed') {
- this._notifyOnFirstChangeAfterFailure = true;
+ if (props.loginState === 'failed') {
+ this._shouldResetLoginError = true;
}
this._footerAnimationStyle = Styles.createAnimatedViewStyle({
@@ -68,11 +71,11 @@ export default class Login extends Component<Props, State> {
componentDidUpdate(prevProps: Props, _prevState: State) {
if (
- this.props.account.status !== prevProps.account.status &&
- this.props.account.status === 'failed' &&
- !this._notifyOnFirstChangeAfterFailure
+ this.props.loginState !== prevProps.loginState &&
+ this.props.loginState === 'failed' &&
+ !this._shouldResetLoginError
) {
- this._notifyOnFirstChangeAfterFailure = true;
+ this._shouldResetLoginError = true;
// focus on login field when failed to log in
const accountInput = this._accountInput;
@@ -88,7 +91,7 @@ export default class Login extends Component<Props, State> {
render() {
return (
<Layout>
- <Header showSettings={true} onSettings={this.props.onSettings} />
+ <Header showSettings={true} onSettings={this.props.openSettings} />
<Container>
<View style={styles.login_form}>
{this._getStatusIcon()}
@@ -110,7 +113,7 @@ export default class Login extends Component<Props, State> {
);
}
- _onCreateAccount = () => this.props.onExternalLink('createAccount');
+ _onCreateAccount = () => this.props.openExternalLink('createAccount');
_onFocus = () => {
this.setState({ isActive: true });
@@ -177,23 +180,24 @@ export default class Login extends Component<Props, State> {
}
_onLogin = () => {
- const accountToken = this.props.account.accountToken;
+ const accountToken = this.props.accountToken;
if (accountToken && accountToken.length > 0) {
- this.props.onLogin(accountToken);
+ this.props.login(accountToken);
}
};
_onInputChange = (value: string) => {
- // notify delegate on first change after login failure
- if (this._notifyOnFirstChangeAfterFailure) {
- this._notifyOnFirstChangeAfterFailure = false;
- this.props.onFirstChangeAfterFailure();
+ // reset error when user types in the new account number
+ if (this._shouldResetLoginError) {
+ this._shouldResetLoginError = false;
+ this.props.resetLoginError();
}
- this.props.onAccountTokenChange(value);
+
+ this.props.updateAccountToken(value);
};
_formTitle() {
- switch (this.props.account.status) {
+ switch (this.props.loginState) {
case 'logging in':
return 'Logging in...';
case 'failed':
@@ -206,10 +210,10 @@ export default class Login extends Component<Props, State> {
}
_formSubtitle() {
- const { status, error } = this.props.account;
- switch (status) {
+ const { loginState, loginError } = this.props;
+ switch (loginState) {
case 'failed':
- return (error && error.message) || 'Unknown error';
+ return (loginError && loginError.message) || 'Unknown error';
case 'logging in':
return 'Checking account number';
default:
@@ -227,7 +231,7 @@ export default class Login extends Component<Props, State> {
}
_getStatusIconPath(): ?string {
- switch (this.props.account.status) {
+ switch (this.props.loginState) {
case 'logging in':
return 'icon-spinner';
case 'failed':
@@ -245,7 +249,7 @@ export default class Login extends Component<Props, State> {
classes.push(styles.account_input_group__active);
}
- switch (this.props.account.status) {
+ switch (this.props.loginState) {
case 'logging in':
classes.push(styles.account_input_group__inactive);
break;
@@ -258,10 +262,9 @@ export default class Login extends Component<Props, State> {
}
_accountInputButtonStyles(): Array<Object> {
- const { status } = this.props.account;
const classes = [styles.input_button];
- if (status === 'logging in') {
+ if (this.props.loginState === 'logging in') {
classes.push(styles.input_button__invisible);
}
@@ -271,14 +274,14 @@ export default class Login extends Component<Props, State> {
}
_accountInputArrowStyles(): Array<Object> {
- const { accountToken, status } = this.props.account;
+ const { accountToken, loginState } = this.props;
const classes = [styles.input_arrow];
if (accountToken && accountToken.length > 0) {
classes.push(styles.input_arrow__active);
}
- if (status === 'logging in') {
+ if (loginState === 'logging in') {
classes.push(styles.input_arrow__invisible);
}
@@ -286,40 +289,54 @@ export default class Login extends Component<Props, State> {
}
_shouldActivateLoginButton() {
- const { accountToken } = this.props.account;
+ const { accountToken } = this.props;
return accountToken && accountToken.length > 0;
}
_shouldEnableAccountInput() {
// enable account input always except when "logging in"
- return this.props.account.status !== 'logging in';
+ return this.props.loginState !== 'logging in';
}
_shouldShowAccountHistory() {
return (
this._shouldEnableAccountInput() &&
this.state.isActive &&
- this.props.account.accountHistory.length > 0
+ this.props.accountHistory.length > 0
);
}
_shouldShowLoginForm() {
- return this.props.account.status !== 'ok';
+ return this.props.loginState !== 'ok';
}
_shouldShowFooter() {
- const { status } = this.props.account;
- return (status === 'none' || status === 'failed') && !this._shouldShowAccountHistory();
+ return (
+ (this.props.loginState === 'none' || this.props.loginState === 'failed') &&
+ !this._shouldShowAccountHistory()
+ );
}
_onSelectAccountFromHistory = (accountToken) => {
- this.props.onAccountTokenChange(accountToken);
- this.props.onLogin(accountToken);
+ this.props.updateAccountToken(accountToken);
+ this.props.login(accountToken);
};
- _createLoginForm() {
- const { accountHistory, accountToken } = this.props.account;
+ _onRemoveAccountFromHistory = (accountToken) => {
+ this._removeAccountFromHistory(accountToken);
+ };
+ async _removeAccountFromHistory(accountToken: AccountToken) {
+ try {
+ await this.props.removeAccountTokenFromHistory(accountToken);
+
+ // TODO: Remove account from memory
+ } catch (error) {
+ // TODO: Show error
+ }
+ }
+
+ _createLoginForm() {
return (
<View>
<Text style={styles.subtitle}>{this._formSubtitle()}</Text>
@@ -334,7 +351,7 @@ export default class Login extends Component<Props, State> {
onBlur={this._onBlur}
onChange={this._onInputChange}
onEnter={this._onLogin}
- value={accountToken || ''}
+ value={this.props.accountToken || ''}
disabled={!this._shouldEnableAccountInput()}
autoFocus={true}
ref={(ref) => (this._accountInput = ref)}
@@ -356,9 +373,9 @@ export default class Login extends Component<Props, State> {
<Accordion height={this._shouldShowAccountHistory() ? 'auto' : 0}>
{
<AccountDropdown
- items={accountHistory.slice().reverse()}
+ items={this.props.accountHistory.slice().reverse()}
onSelect={this._onSelectAccountFromHistory}
- onRemove={this.props.onRemoveAccountTokenFromHistory}
+ onRemove={this._onRemoveAccountFromHistory}
/>
}
</Accordion>
diff --git a/app/components/SelectLocation.js b/app/components/SelectLocation.js
index 5864ed55a3..14d0a04f9c 100644
--- a/app/components/SelectLocation.js
+++ b/app/components/SelectLocation.js
@@ -14,7 +14,7 @@ import type {
RelayLocationRedux,
RelayLocationCityRedux,
} from '../redux/settings/reducers';
-import type { RelayLocation } from '../lib/ipc-facade';
+import type { RelayLocation } from '../lib/daemon-rpc';
export type SelectLocationProps = {
settings: SettingsReduxState,
diff --git a/app/containers/ConnectPage.js b/app/containers/ConnectPage.js
index 532dfc8626..21cc1a7133 100644
--- a/app/containers/ConnectPage.js
+++ b/app/containers/ConnectPage.js
@@ -2,11 +2,11 @@
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
-import { push } from 'react-router-redux';
+import { push as pushHistory } from 'react-router-redux';
import { links } from '../config';
import Connect from '../components/Connect';
import connectActions from '../redux/connection/actions';
-import { openLink } from '../lib/platform';
+import { openLink, log } from '../lib/platform';
import type { ReduxState, ReduxDispatch } from '../redux/store';
import type { SharedRouteProps } from '../routes';
@@ -55,25 +55,33 @@ const mapStateToProps = (state: ReduxState) => {
};
const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => {
- const { connect, disconnect, copyIPAddress } = bindActionCreators(connectActions, dispatch);
- const { push: pushHistory } = bindActionCreators({ push }, dispatch);
+ const { copyIPAddress } = bindActionCreators(connectActions, dispatch);
+ const history = bindActionCreators({ push: pushHistory }, dispatch);
const { backend } = props;
return {
onSettings: () => {
- pushHistory('/settings');
+ history.push('/settings');
},
onSelectLocation: () => {
- pushHistory('/select-location');
+ history.push('/select-location');
},
onConnect: () => {
- connect(backend);
+ try {
+ backend.connectTunnel();
+ } catch (error) {
+ log.error(`Failed to connect the tunnel: ${error.message}`);
+ }
},
onCopyIP: () => {
copyIPAddress();
},
onDisconnect: () => {
- disconnect(backend);
+ try {
+ backend.disconnectTunnel();
+ } catch (error) {
+ log.error(`Failed to disconnect the tunnel: ${error.message}`);
+ }
},
onExternalLink: (type) => openLink(links[type]),
};
diff --git a/app/containers/LoginPage.js b/app/containers/LoginPage.js
index 807c141799..fcf4dc8b7f 100644
--- a/app/containers/LoginPage.js
+++ b/app/containers/LoginPage.js
@@ -11,9 +11,15 @@ import { openLink } from '../lib/platform';
import type { ReduxState, ReduxDispatch } from '../redux/store';
import type { SharedRouteProps } from '../routes';
-const mapStateToProps = (state: ReduxState) => ({
- account: state.account,
-});
+const mapStateToProps = (state: ReduxState) => {
+ const { accountToken, accountHistory, error, status } = state.account;
+ return {
+ accountToken,
+ accountHistory,
+ loginError: error,
+ loginState: status,
+ };
+};
const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => {
const { push: pushHistory } = bindActionCreators({ push }, dispatch);
const { login, resetLoginError, updateAccountToken } = bindActionCreators(
@@ -22,20 +28,18 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) =>
);
const { backend } = props;
return {
- onSettings: () => {
+ openSettings: () => {
pushHistory('/settings');
},
- onLogin: (account) => {
+ login: (account) => {
login(backend, account);
},
- onFirstChangeAfterFailure: () => {
+ resetLoginError: () => {
resetLoginError();
},
- onExternalLink: (type) => openLink(links[type]),
- onAccountTokenChange: (token) => {
- updateAccountToken(token);
- },
- onRemoveAccountTokenFromHistory: (token) => backend.removeAccountFromHistory(token),
+ openExternalLink: (type) => openLink(links[type]),
+ updateAccountToken: updateAccountToken,
+ removeAccountTokenFromHistory: (token) => backend.removeAccountFromHistory(token),
};
};
diff --git a/app/containers/SelectLocationPage.js b/app/containers/SelectLocationPage.js
index b2ae476912..29b47c14a3 100644
--- a/app/containers/SelectLocationPage.js
+++ b/app/containers/SelectLocationPage.js
@@ -26,7 +26,7 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) =>
await backend.updateRelaySettings(relayUpdate);
await backend.fetchRelaySettings();
- await backend.connect();
+ await backend.connectTunnel();
pushHistory('/connect');
} catch (e) {
diff --git a/app/containers/SettingsPage.js b/app/containers/SettingsPage.js
index 2bcef79730..2c68fea676 100644
--- a/app/containers/SettingsPage.js
+++ b/app/containers/SettingsPage.js
@@ -3,10 +3,9 @@
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { push } from 'react-router-redux';
-import { version } from '../../package.json';
import Settings from '../components/Settings';
import { links } from '../config';
-import { openLink, exit } from '../lib/platform';
+import { getAppVersion, openLink, exit } from '../lib/platform';
import type { ReduxState, ReduxDispatch } from '../redux/store';
import type { SharedRouteProps } from '../routes';
@@ -14,7 +13,7 @@ import type { SharedRouteProps } from '../routes';
const mapStateToProps = (state: ReduxState) => ({
account: state.account,
settings: state.settings,
- version: version,
+ version: getAppVersion(),
});
const mapDispatchToProps = (dispatch: ReduxDispatch, _props: SharedRouteProps) => {
const { push: pushHistory } = bindActionCreators({ push }, dispatch);
diff --git a/app/lib/backend.js b/app/lib/backend.js
index b24b495449..3e128da0b1 100644
--- a/app/lib/backend.js
+++ b/app/lib/backend.js
@@ -1,15 +1,24 @@
// @flow
-import { log } from '../lib/platform';
-import { IpcFacade, RealIpc } from './ipc-facade';
-import { JsonRpcError, TimeOutError } from './jsonrpc-ws-ipc';
+import { ipcRenderer } from 'electron';
+import { bindActionCreators } from 'redux';
+import { push as pushHistory } from 'react-router-redux';
+import {
+ RemoteError as JsonRpcTransportRemoteError,
+ TimeOutError as JsonRpcTransportTimeOutError,
+} from './jsonrpc-transport';
import accountActions from '../redux/account/actions';
import connectionActions from '../redux/connection/actions';
import settingsActions from '../redux/settings/actions';
-import { push } from 'react-router-redux';
+import { log } from '../lib/platform';
+import type { RpcCredentials as OriginalRpcCredentials } from './rpc-address-file';
+import type {
+ DaemonRpcProtocol,
+ ConnectionObserver as DaemonConnectionObserver,
+} from './daemon-rpc';
import type { ReduxStore } from '../redux/store';
-import type { AccountToken, BackendState, RelaySettingsUpdate } from './ipc-facade';
+import type { AccountToken, BackendState, RelaySettingsUpdate } from './daemon-rpc';
import type { ConnectionState } from '../redux/connection/reducers';
export class NoCreditError extends Error {
@@ -74,236 +83,187 @@ export class UnknownError extends Error {
}
}
-export type IpcCredentials = {
- connectionString: string,
- sharedSecret: string,
-};
-export function parseIpcCredentials(data: string): ?IpcCredentials {
- const [connectionString, sharedSecret] = data.split('\n', 2);
- if (connectionString && sharedSecret !== undefined) {
- return {
- connectionString,
- sharedSecret,
- };
- } else {
- return null;
+export class CredentialsRequestError extends Error {
+ _reason: Error;
+
+ constructor(reason: Error) {
+ super('Failed to request the RPC credentials');
+ this._reason = reason;
+ }
+
+ get reason(): Error {
+ return this._reason;
}
}
+export type RpcCredentials = OriginalRpcCredentials;
+export interface RpcCredentialsProvider {
+ request(): Promise<RpcCredentials>;
+}
+
/**
* Backend implementation
*/
export class Backend {
- _ipc: IpcFacade;
- _credentials: ?IpcCredentials;
- _authenticationPromise: ?Promise<void>;
- _store: ReduxStore;
-
- constructor(store: ReduxStore, credentials?: IpcCredentials, ipc: ?IpcFacade) {
- this._store = store;
- this._credentials = credentials;
-
- if (ipc) {
- this._ipc = ipc;
+ _daemonRpc: DaemonRpcProtocol;
+ _credentialsProvider: RpcCredentialsProvider;
+ _reconnectBackoff = new ReconnectionBackoff();
+ _credentials: ?RpcCredentials;
+ _openConnectionObserver: ?DaemonConnectionObserver;
+ _closeConnectionObserver: ?DaemonConnectionObserver;
+ _reduxActions: *;
- // force to re-authenticate when connection closed
- this._ipc.setCloseConnectionHandler(() => {
- this._authenticationPromise = null;
- });
+ constructor(
+ store: ReduxStore,
+ rpc: DaemonRpcProtocol,
+ credentialsProvider: RpcCredentialsProvider,
+ ) {
+ this._daemonRpc = rpc;
+ this._credentialsProvider = credentialsProvider;
- this._registerIpcListeners();
- this._startReachability();
- }
- }
+ this._reduxActions = {
+ account: bindActionCreators(accountActions, store.dispatch),
+ connection: bindActionCreators(connectionActions, store.dispatch),
+ settings: bindActionCreators(settingsActions, store.dispatch),
+ history: bindActionCreators({ push: pushHistory }, store.dispatch),
+ };
- setCredentials(credentials: IpcCredentials) {
- log.debug('Got connection info to backend', credentials.connectionString);
- this._credentials = credentials;
+ this._openConnectionObserver = rpc.addOpenConnectionObserver(() => {
+ this._onOpenConnection();
+ });
- if (this._ipc) {
- this._credentials = credentials;
- } else {
- this._ipc = new RealIpc(credentials.connectionString);
+ this._closeConnectionObserver = rpc.addCloseConnectionObserver((error) => {
+ this._onCloseConnection(error);
+ });
- // force to re-authenticate when connection closed
- this._ipc.setCloseConnectionHandler(() => {
- this._authenticationPromise = null;
- });
- }
- this._registerIpcListeners();
+ this._setupReachability();
}
- async sync() {
- log.info('Syncing with the backend...');
+ dispose() {
+ const openConnectionObserver = this._openConnectionObserver;
+ const closeConnectionObserver = this._closeConnectionObserver;
- try {
- await this._fetchRelayLocations();
- } catch (e) {
- log.error('Failed to fetch the relay locations: ', e.message);
+ if (openConnectionObserver) {
+ openConnectionObserver.unsubscribe();
+ this._openConnectionObserver = null;
}
- try {
- await this._fetchLocation();
- } catch (e) {
- log.error('Failed to fetch the location: ', e.message);
+ if (closeConnectionObserver) {
+ closeConnectionObserver.unsubscribe();
+ this._closeConnectionObserver = null;
}
+ }
- try {
- await this._fetchAllowLan();
- } catch (e) {
- log.error('Failed to fetch the LAN sharing policy: ', e.message);
- }
+ connect() {
+ this._connectToDaemon();
+ }
- await this._fetchAccountHistory();
+ disconnect() {
+ this._daemonRpc.disconnect();
}
async login(accountToken: AccountToken) {
- log.debug('Attempting to login');
+ const actions = this._reduxActions;
+ actions.account.startLogin(accountToken);
- this._store.dispatch(accountActions.startLogin(accountToken));
+ log.debug('Attempting to login');
try {
- await this._ensureAuthenticated();
-
- const accountData = await this._ipc.getAccountData(accountToken);
+ const accountData = await this._daemonRpc.getAccountData(accountToken);
+ await this._daemonRpc.setAccount(accountToken);
- log.debug('Account exists', accountData);
-
- await this._ipc.setAccount(accountToken);
-
- log.info('Log in complete');
-
- this._store.dispatch(accountActions.loginSuccessful(accountData.expiry));
- await this.fetchRelaySettings();
+ actions.account.loginSuccessful(accountData.expiry);
// Redirect the user after some time to allow for
// the 'Login Successful' screen to be visible
setTimeout(() => {
- this._store.dispatch(push('/connect'));
+ actions.history.push('/connect');
log.debug('Autoconnecting...');
- this.connect();
+ this.connectTunnel();
}, 1000);
+ } catch (error) {
+ log.error('Failed to log in,', error.message);
- await this._fetchAccountHistory();
- } catch (e) {
- log.error('Failed to log in,', e.message);
-
- const error = this._rpcErrorToBackendError(e);
- this._store.dispatch(accountActions.loginFailed(error));
+ actions.account.loginFailed(this._rpcErrorToBackendError(error));
}
}
- _rpcErrorToBackendError(e) {
- if (e instanceof JsonRpcError) {
- switch (e.code) {
- case -200: // Account doesn't exist
- return new InvalidAccountError();
- case -32603: // Internal error
- // We treat all internal backend errors as the user cannot reach
- // api.mullvad.net. This is not always true of course, but it is
- // true so often that we choose to disregard the other edge cases
- // for now.
- return new CommunicationError();
- }
- } else if (e instanceof TimeOutError) {
- return new CommunicationError();
- } else if (e instanceof NoDaemonError) {
- return e;
- }
-
- return new UnknownError(e.message);
- }
-
async autologin() {
- try {
- log.debug('Attempting to log in automatically');
+ const actions = this._reduxActions;
+ actions.account.startLogin();
- await this._ensureAuthenticated();
+ log.debug('Attempting to log in automatically');
- this._store.dispatch(accountActions.startLogin());
-
- const accountToken = await this._ipc.getAccount();
+ try {
+ const accountToken = await this._daemonRpc.getAccount();
if (!accountToken) {
throw new NoAccountError();
}
- log.debug('The backend had an account number stored: ', accountToken);
- this._store.dispatch(accountActions.startLogin(accountToken));
+ log.debug(`The backend had an account number stored: ${accountToken}`);
+ actions.account.startLogin(accountToken);
- const accountData = await this._ipc.getAccountData(accountToken);
- log.debug('The stored account number still exists', accountData);
+ const accountData = await this._daemonRpc.getAccountData(accountToken);
+ log.debug('The stored account number still exists:', accountData);
- this._store.dispatch(accountActions.loginSuccessful(accountData.expiry));
- this._store.dispatch(push('/connect'));
+ actions.account.loginSuccessful(accountData.expiry);
+ actions.history.push('/connect');
} catch (e) {
log.warn('Unable to autologin,', e.message);
- this._store.dispatch(accountActions.autoLoginFailed());
- this._store.dispatch(push('/'));
+ actions.account.autoLoginFailed();
+ actions.history.push('/');
throw e;
}
}
async logout() {
- // @TODO: What does it mean for a logout to be successful or failed?
- try {
- await this._ensureAuthenticated();
- await this._ipc.setAccount(null);
+ const actions = this._reduxActions;
- this._store.dispatch(accountActions.loggedOut());
-
- // disconnect user during logout
- await this.disconnect();
-
- this._store.dispatch(push('/'));
+ try {
+ await Promise.all([
+ this.disconnectTunnel(),
+ this._daemonRpc.setAccount(null),
+ this.fetchAccountHistory(),
+ ]);
+ actions.account.loggedOut();
+ actions.history.push('/');
} catch (e) {
log.info('Failed to logout: ', e.message);
}
}
- async connect() {
+ async connectTunnel() {
+ const actions = this._reduxActions;
+
try {
- const currentState = await this._ipc.getState();
+ const currentState = await this._daemonRpc.getState();
if (currentState.state === 'secured') {
log.debug('Refusing to connect as connection is already secured');
- this._store.dispatch(connectionActions.connected());
- return;
+ actions.connection.connected();
+ } else {
+ actions.connection.connecting();
+ await this._daemonRpc.connectTunnel();
}
-
- this._store.dispatch(connectionActions.connecting());
-
- await this._ensureAuthenticated();
- await this._ipc.connect();
- } catch (e) {
- log.error('Failed to connect: ', e.message);
- this._store.dispatch(connectionActions.disconnected());
+ } catch (error) {
+ actions.connection.disconnected();
+ throw error;
}
}
- async disconnect() {
- // @TODO: Failure modes
- try {
- await this._ensureAuthenticated();
- await this._ipc.disconnect();
- } catch (e) {
- log.error('Failed to disconnect: ', e.message);
- }
+ disconnectTunnel() {
+ return this._daemonRpc.disconnectTunnel();
}
- async updateRelaySettings(relaySettings: RelaySettingsUpdate) {
- try {
- await this._ensureAuthenticated();
- await this._ipc.updateRelaySettings(relaySettings);
- } catch (e) {
- log.error('Failed to update relay settings: ', e.message);
- }
+ updateRelaySettings(relaySettings: RelaySettingsUpdate) {
+ return this._daemonRpc.updateRelaySettings(relaySettings);
}
async fetchRelaySettings() {
- await this._ensureAuthenticated();
+ const actions = this._reduxActions;
+ const relaySettings = await this._daemonRpc.getRelaySettings();
- const relaySettings = await this._ipc.getRelaySettings();
log.debug('Got relay settings from backend', JSON.stringify(relaySettings));
if (relaySettings.normal) {
@@ -327,11 +287,9 @@ export class Backend {
payload.protocol = protocol === 'any' ? protocol : protocol.only;
}
- this._store.dispatch(
- settingsActions.updateRelay({
- normal: payload,
- }),
- );
+ actions.settings.updateRelay({
+ normal: payload,
+ });
} else if (relaySettings.custom_tunnel_endpoint) {
const custom_tunnel_endpoint = relaySettings.custom_tunnel_endpoint;
const {
@@ -341,62 +299,45 @@ export class Backend {
},
} = custom_tunnel_endpoint;
- this._store.dispatch(
- settingsActions.updateRelay({
- custom_tunnel_endpoint: {
- host,
- port,
- protocol,
- },
- }),
- );
+ actions.settings.updateRelay({
+ custom_tunnel_endpoint: {
+ host,
+ port,
+ protocol,
+ },
+ });
}
}
async updateAccountExpiry() {
- const ipc = this._ipc;
- const store = this._store;
-
+ const actions = this._reduxActions;
try {
- await this._ensureAuthenticated();
-
- const accountToken = await this._ipc.getAccount();
+ const accountToken = await this._daemonRpc.getAccount();
if (!accountToken) {
throw new NoAccountError();
}
-
- const accountData = await ipc.getAccountData(accountToken);
- store.dispatch(accountActions.updateAccountExpiry(accountData.expiry));
+ const accountData = await this._daemonRpc.getAccountData(accountToken);
+ actions.account.updateAccountExpiry(accountData.expiry);
} catch (e) {
log.error(`Failed to update account expiry: ${e.message}`);
}
}
- async removeAccountFromHistory(accountToken: AccountToken) {
- try {
- await this._ensureAuthenticated();
- await this._ipc.removeAccountFromHistory(accountToken);
- await this._fetchAccountHistory();
- } catch (e) {
- log.error('Failed to remove account token from history', e.message);
- }
+ async removeAccountFromHistory(accountToken: AccountToken): Promise<void> {
+ await this._daemonRpc.removeAccountFromHistory(accountToken);
+ await this.fetchAccountHistory();
}
- async _fetchAccountHistory() {
- try {
- await this._ensureAuthenticated();
- const accountHistory = await this._ipc.getAccountHistory();
- this._store.dispatch(accountActions.updateAccountHistory(accountHistory));
- } catch (e) {
- log.info('Failed to fetch account history,', e.message);
- throw e;
- }
- }
+ async fetchAccountHistory(): Promise<void> {
+ const actions = this._reduxActions;
- async _fetchRelayLocations() {
- await this._ensureAuthenticated();
+ const accountHistory = await this._daemonRpc.getAccountHistory();
+ actions.account.updateAccountHistory(accountHistory);
+ }
- const locations = await this._ipc.getRelayLocations();
+ async fetchRelayLocations() {
+ const actions = this._reduxActions;
+ const locations = await this._daemonRpc.getRelayLocations();
log.info('Got relay locations');
@@ -413,13 +354,12 @@ export class Backend {
})),
}));
- this._store.dispatch(settingsActions.updateRelayLocations(storedLocations));
+ actions.settings.updateRelayLocations(storedLocations);
}
- async _fetchLocation() {
- await this._ensureAuthenticated();
-
- const location = await this._ipc.getLocation();
+ async fetchLocation() {
+ const actions = this._reduxActions;
+ const location = await this._daemonRpc.getLocation();
log.info('Got location from daemon');
@@ -432,33 +372,112 @@ export class Backend {
mullvadExitIp: location.mullvad_exit_ip,
};
- this._store.dispatch(connectionActions.newLocation(locationUpdate));
+ actions.connection.newLocation(locationUpdate);
}
async setAllowLan(allowLan: boolean) {
+ const actions = this._reduxActions;
+ await this._daemonRpc.setAllowLan(allowLan);
+ actions.settings.updateAllowLan(allowLan);
+ }
+
+ async fetchAllowLan() {
+ const actions = this._reduxActions;
+ const allowLan = await this._daemonRpc.getAllowLan();
+ actions.settings.updateAllowLan(allowLan);
+ }
+
+ async fetchSecurityState() {
+ const securityState = await this._daemonRpc.getState();
+ const connectionState = this._securityStateToConnectionState(securityState);
+ this._updateConnectionState(connectionState);
+ }
+
+ async _requestCredentials(): Promise<RpcCredentials> {
try {
- await this._ensureAuthenticated();
- await this._ipc.setAllowLan(allowLan);
+ return await this._credentialsProvider.request();
+ } catch (providerError) {
+ throw new CredentialsRequestError(providerError);
+ }
+ }
- this._store.dispatch(settingsActions.updateAllowLan(allowLan));
- } catch (e) {
- log.error('Failed to change the LAN sharing policy: ', e.message);
+ async _connectToDaemon(): Promise<void> {
+ let credentials;
+ try {
+ credentials = await this._requestCredentials();
+ } catch (error) {
+ log.error(`Cannot request the RPC credentials: ${error.message}`);
+ return;
}
+
+ this._credentials = credentials;
+ this._daemonRpc.connect(credentials.connectionString);
}
- async _fetchAllowLan() {
- await this._ensureAuthenticated();
+ async _onOpenConnection() {
+ this._reconnectBackoff.reset();
- const allowLan = await this._ipc.getAllowLan();
- this._store.dispatch(settingsActions.updateAllowLan(allowLan));
+ // authenticate once connected
+ const credentials = this._credentials;
+ try {
+ if (!credentials) {
+ throw new Error('Credentials cannot be unset after connection is established.');
+ }
+ await this._authenticate(credentials.sharedSecret);
+ } catch (error) {
+ log.error(`Cannot authenticate: ${error.message}`);
+ }
+
+ // autologin
+ try {
+ await this.autologin();
+ } catch (error) {
+ if (error instanceof NoAccountError) {
+ log.debug('No previously configured account set, showing window');
+ ipcRenderer.send('show-window');
+ } else {
+ log.error(`Failed to autologin: ${error.message}`);
+ }
+ }
+
+ // make sure to re-subscribe to state notifications when connection is re-established.
+ try {
+ await this._subscribeStateListener();
+ } catch (error) {
+ log.error(`Cannot subscribe for RPC notifications: ${error.message}`);
+ }
+
+ // fetch initial state
+ try {
+ await this._fetchInitialState();
+ } catch (error) {
+ log.error(`Cannot fetch initial state: ${error.message}`);
+ }
+
+ // auto connect the tunnel
+ try {
+ await this.connectTunnel();
+ } catch (error) {
+ log.error(`Cannot autoconnect the tunnel: ${error.message}`);
+ }
}
- async fetchSecurityState() {
- await this._ensureAuthenticated();
+ async _onCloseConnection(error: ?Error) {
+ if (error) {
+ log.debug(`Lost connection to daemon: ${error.message}`);
- const securityState = await this._ipc.getState();
- const connectionState = this._securityStateToConnectionState(securityState);
- this._dispatchConnectionState(connectionState);
+ const recover = async () => {
+ try {
+ await this.connect();
+ } catch (error) {
+ log.error(`Failed to reconnect: ${error.message}`);
+ }
+ };
+
+ this._reconnectBackoff.attempt(() => {
+ recover();
+ });
+ }
}
/**
@@ -466,35 +485,63 @@ export class Backend {
* This is currently done via HTML5 APIs but will be replaced later
* with proper backend integration.
*/
- _startReachability() {
+ _setupReachability() {
+ const actions = this._reduxActions;
+
window.addEventListener('online', () => {
- this._store.dispatch(connectionActions.online());
+ actions.connection.online();
});
window.addEventListener('offline', () => {
- // force disconnect since there is no real connection anyway.
- this.disconnect();
- this._store.dispatch(connectionActions.offline());
+ actions.connection.offline();
});
- // update online status in background
- setTimeout(() => {
- const action = navigator.onLine ? connectionActions.online() : connectionActions.offline();
-
- this._store.dispatch(action);
- }, 0);
+ if (navigator.onLine) {
+ actions.connection.online();
+ } else {
+ actions.connection.offline();
+ }
}
- async _registerIpcListeners() {
- await this._ensureAuthenticated();
- this._ipc.registerStateListener((newState) => {
- const connectionState = this._securityStateToConnectionState(newState);
- log.debug(`Got new state from backend {state: ${newState.state}, \
- target_state: ${newState.target_state}}, translated to '${connectionState}'`);
- this._dispatchConnectionState(connectionState);
- this.sync();
+ async _subscribeStateListener() {
+ await this._daemonRpc.subscribeStateListener((newState, error) => {
+ if (error) {
+ log.error(`Received an error when processing the incoming state change: ${error.message}`);
+ }
+
+ if (newState) {
+ const connectionState = this._securityStateToConnectionState(newState);
+
+ log.debug(
+ `Got new state from backend {state: ${newState.state}, target_state: ${
+ newState.target_state
+ }}, translated to '${connectionState}'`,
+ );
+
+ this._updateConnectionState(connectionState);
+ this._refreshStateOnChange();
+ }
});
}
+ async _fetchInitialState() {
+ return Promise.all([
+ this.fetchSecurityState(),
+ this.fetchRelaySettings(),
+ this.fetchRelayLocations(),
+ this.fetchAllowLan(),
+ this.fetchLocation(),
+ this.fetchAccountHistory(),
+ ]);
+ }
+
+ async _refreshStateOnChange() {
+ try {
+ await this.fetchLocation();
+ } catch (error) {
+ log.error(`Failed to fetch the location: ${error.message}`);
+ }
+ }
+
_securityStateToConnectionState(backendState: BackendState): ConnectionState {
if (backendState.state === 'unsecured' && backendState.target_state === 'secured') {
return 'connecting';
@@ -506,39 +553,79 @@ export class Backend {
throw new Error('Unsupported state/target state combination: ' + JSON.stringify(backendState));
}
- _dispatchConnectionState(connectionState: ConnectionState) {
+ _updateConnectionState(connectionState: ConnectionState) {
+ const actions = this._reduxActions;
switch (connectionState) {
case 'connecting':
- this._store.dispatch(connectionActions.connecting());
+ actions.connection.connecting();
break;
case 'connected':
- this._store.dispatch(connectionActions.connected());
+ actions.connection.connected();
break;
case 'disconnected':
- this._store.dispatch(connectionActions.disconnected());
+ actions.connection.disconnected();
break;
}
}
- _ensureAuthenticated(): Promise<void> {
- const credentials = this._credentials;
- if (credentials) {
- if (!this._authenticationPromise) {
- this._authenticationPromise = this._authenticate(credentials.sharedSecret);
+ _rpcErrorToBackendError(e) {
+ if (e instanceof JsonRpcTransportRemoteError) {
+ switch (e.code) {
+ case -200: // Account doesn't exist
+ return new InvalidAccountError();
+ case -32603: // Internal error
+ // We treat all internal backend errors as the user cannot reach
+ // api.mullvad.net. This is not always true of course, but it is
+ // true so often that we choose to disregard the other edge cases
+ // for now.
+ return new CommunicationError();
}
- return this._authenticationPromise;
- } else {
- return Promise.reject(new NoDaemonError());
+ } else if (e instanceof JsonRpcTransportTimeOutError) {
+ return new CommunicationError();
+ } else if (e instanceof NoDaemonError) {
+ return e;
}
+
+ return new UnknownError(e.message);
}
async _authenticate(sharedSecret: string) {
try {
- await this._ipc.authenticate(sharedSecret);
+ await this._daemonRpc.authenticate(sharedSecret);
log.info('Authenticated with backend');
} catch (e) {
- log.error('Failed to authenticate with backend: ', e.message);
+ log.error(`Failed to authenticate with backend: ${e.message}`);
throw e;
}
}
}
+
+/*
+ * Used to calculate the time to wait before reconnecting
+ * the websocket.
+ *
+ * It uses a linear backoff function that goes from 500ms
+ * to 3000ms
+ */
+class ReconnectionBackoff {
+ _attempt: number;
+
+ constructor() {
+ this._attempt = 0;
+ }
+
+ attempt(handler: () => void) {
+ setTimeout(handler, this._getIncreasedBackoff());
+ }
+
+ reset() {
+ this._attempt = 0;
+ }
+
+ _getIncreasedBackoff() {
+ if (this._attempt < 6) {
+ this._attempt++;
+ }
+ return this._attempt * 500;
+ }
+}
diff --git a/app/lib/daemon-rpc.js b/app/lib/daemon-rpc.js
new file mode 100644
index 0000000000..2ef3d06e13
--- /dev/null
+++ b/app/lib/daemon-rpc.js
@@ -0,0 +1,387 @@
+// @flow
+
+import JsonRpcTransport from './jsonrpc-transport';
+import {
+ object,
+ maybe,
+ string,
+ number,
+ boolean,
+ enumeration,
+ arrayOf,
+ oneOf,
+} from 'validated/schema';
+import { validate } from 'validated/object';
+
+import type { Node as SchemaNode } from 'validated/schema';
+
+export type AccountData = { expiry: string };
+export type AccountToken = string;
+export type Ip = string;
+export type Location = {
+ ip: Ip,
+ country: string,
+ city: ?string,
+ latitude: number,
+ longitude: number,
+ mullvad_exit_ip: boolean,
+};
+const LocationSchema = object({
+ ip: string,
+ country: string,
+ city: maybe(string),
+ latitude: number,
+ longitude: number,
+ mullvad_exit_ip: boolean,
+});
+
+export type SecurityState = 'secured' | 'unsecured';
+export type BackendState = {
+ state: SecurityState,
+ target_state: SecurityState,
+};
+
+export type RelayProtocol = 'tcp' | 'udp';
+export type RelayLocation = {| city: [string, string] |} | {| country: string |};
+
+type OpenVpnParameters = {
+ port: 'any' | { only: number },
+ protocol: 'any' | { only: RelayProtocol },
+};
+
+type TunnelOptions<TOpenVpnParameters> = {
+ openvpn: TOpenVpnParameters,
+};
+
+type RelaySettingsNormal<TTunnelOptions> = {
+ location:
+ | 'any'
+ | {
+ only: RelayLocation,
+ },
+ tunnel:
+ | 'any'
+ | {
+ only: TTunnelOptions,
+ },
+};
+
+// types describing the structure of RelaySettings
+export type RelaySettingsCustom = {
+ host: string,
+ tunnel: {
+ openvpn: {
+ port: number,
+ protocol: RelayProtocol,
+ },
+ },
+};
+export type RelaySettings =
+ | {|
+ normal: RelaySettingsNormal<TunnelOptions<OpenVpnParameters>>,
+ |}
+ | {|
+ custom_tunnel_endpoint: RelaySettingsCustom,
+ |};
+
+// types describing the partial update of RelaySettings
+export type RelaySettingsNormalUpdate = $Shape<
+ RelaySettingsNormal<TunnelOptions<$Shape<OpenVpnParameters>>>,
+>;
+export type RelaySettingsUpdate =
+ | {|
+ normal: RelaySettingsNormalUpdate,
+ |}
+ | {|
+ custom_tunnel_endpoint: RelaySettingsCustom,
+ |};
+
+const constraint = <T>(constraintValue: SchemaNode<T>) => {
+ return oneOf(
+ string, // any
+ object({
+ only: constraintValue,
+ }),
+ );
+};
+
+const RelaySettingsSchema = oneOf(
+ object({
+ normal: object({
+ location: constraint(
+ oneOf(
+ object({
+ city: arrayOf(string),
+ }),
+ object({
+ country: string,
+ }),
+ ),
+ ),
+ tunnel: constraint(
+ object({
+ openvpn: object({
+ port: constraint(number),
+ protocol: constraint(enumeration('udp', 'tcp')),
+ }),
+ }),
+ ),
+ }),
+ }),
+ object({
+ custom_tunnel_endpoint: object({
+ host: string,
+ tunnel: object({
+ openvpn: object({
+ port: number,
+ protocol: enumeration('udp', 'tcp'),
+ }),
+ }),
+ }),
+ }),
+);
+
+export type RelayList = {
+ countries: Array<RelayListCountry>,
+};
+
+export type RelayListCountry = {
+ name: string,
+ code: string,
+ cities: Array<RelayListCity>,
+};
+
+export type RelayListCity = {
+ name: string,
+ code: string,
+ latitude: number,
+ longitude: number,
+ has_active_relays: boolean,
+};
+
+const RelayListSchema = object({
+ countries: arrayOf(
+ object({
+ name: string,
+ code: string,
+ cities: arrayOf(
+ object({
+ name: string,
+ code: string,
+ latitude: number,
+ longitude: number,
+ has_active_relays: boolean,
+ }),
+ ),
+ }),
+ ),
+});
+
+const AccountDataSchema = object({
+ expiry: string,
+});
+
+const allSecurityStates: Array<SecurityState> = ['secured', 'unsecured'];
+const BackendStateSchema = object({
+ state: enumeration(...allSecurityStates),
+ target_state: enumeration(...allSecurityStates),
+});
+
+export interface DaemonRpcProtocol {
+ connect(string): void;
+ disconnect(): void;
+ getAccountData(AccountToken): Promise<AccountData>;
+ getRelayLocations(): Promise<RelayList>;
+ getAccount(): Promise<?AccountToken>;
+ setAccount(accountToken: ?AccountToken): Promise<void>;
+ updateRelaySettings(RelaySettingsUpdate): Promise<void>;
+ getRelaySettings(): Promise<RelaySettings>;
+ setAllowLan(boolean): Promise<void>;
+ getAllowLan(): Promise<boolean>;
+ connectTunnel(): Promise<void>;
+ disconnectTunnel(): Promise<void>;
+ getLocation(): Promise<Location>;
+ getState(): Promise<BackendState>;
+ subscribeStateListener((state: ?BackendState, error: ?Error) => void): Promise<void>;
+ addOpenConnectionObserver(() => void): ConnectionObserver;
+ addCloseConnectionObserver((error: ?Error) => void): ConnectionObserver;
+ authenticate(sharedSecret: string): Promise<void>;
+ getAccountHistory(): Promise<Array<AccountToken>>;
+ removeAccountFromHistory(accountToken: AccountToken): Promise<void>;
+}
+
+export class ResponseParseError extends Error {
+ _validationError: ?Error;
+
+ constructor(message: string, validationError: ?Error) {
+ super(message);
+ this._validationError = validationError;
+ }
+
+ get validationError(): ?Error {
+ return this._validationError;
+ }
+}
+
+export type ConnectionObserver = {
+ unsubscribe: () => void,
+};
+
+export class DaemonRpc implements DaemonRpcProtocol {
+ _transport = new JsonRpcTransport();
+
+ async authenticate(sharedSecret: string): Promise<void> {
+ await this._transport.send('auth', sharedSecret);
+ }
+
+ connect(connectionString: string) {
+ this._transport.connect(connectionString);
+ }
+
+ disconnect() {
+ this._transport.disconnect();
+ }
+
+ addOpenConnectionObserver(handler: () => void): ConnectionObserver {
+ this._transport.on('open', handler);
+ return {
+ unsubscribe: () => {
+ this._transport.off('open', handler);
+ },
+ };
+ }
+
+ addCloseConnectionObserver(handler: (error: ?Error) => void): ConnectionObserver {
+ this._transport.on('close', handler);
+ return {
+ unsubscribe: () => {
+ this._transport.off('close', handler);
+ },
+ };
+ }
+
+ async getAccountData(accountToken: AccountToken): Promise<AccountData> {
+ // send the IPC with 30s timeout since the backend will wait
+ // for a HTTP request before replying
+ const response = await this._transport.send('get_account_data', accountToken, 30000);
+ try {
+ return validate(AccountDataSchema, response);
+ } catch (error) {
+ throw new ResponseParseError('Invalid response from get_account_data', error);
+ }
+ }
+
+ async getRelayLocations(): Promise<RelayList> {
+ const response = await this._transport.send('get_relay_locations');
+ try {
+ return validate(RelayListSchema, response);
+ } catch (error) {
+ throw new ResponseParseError('Invalid response from get_relay_locations', error);
+ }
+ }
+
+ async getAccount(): Promise<?AccountToken> {
+ const response = await this._transport.send('get_account');
+ if (response === null || typeof response === 'string') {
+ return response;
+ } else {
+ throw new ResponseParseError('Invalid response from get_account', null);
+ }
+ }
+
+ async setAccount(accountToken: ?AccountToken): Promise<void> {
+ await this._transport.send('set_account', accountToken);
+ }
+
+ async updateRelaySettings(relaySettings: RelaySettingsUpdate): Promise<void> {
+ await this._transport.send('update_relay_settings', [relaySettings]);
+ }
+
+ async getRelaySettings(): Promise<RelaySettings> {
+ const response = await this._transport.send('get_relay_settings');
+ try {
+ const validatedObject = validate(RelaySettingsSchema, response);
+
+ /* $FlowFixMe:
+ There is no way to express the constraints with string literals, i.e:
+
+ RelaySettingsSchema constraint:
+ oneOf(string, object)
+
+ RelaySettings constraint:
+ 'any' | object
+
+ These two are incompatible so we simply enforce the type for now.
+ */
+ return ((validatedObject: any): RelaySettings);
+ } catch (e) {
+ throw new ResponseParseError('Invalid response from get_relay_settings', e);
+ }
+ }
+
+ async setAllowLan(allowLan: boolean): Promise<void> {
+ await this._transport.send('set_allow_lan', [allowLan]);
+ }
+
+ async getAllowLan(): Promise<boolean> {
+ const response = await this._transport.send('get_allow_lan');
+ if (typeof response === 'boolean') {
+ return response;
+ } else {
+ throw new ResponseParseError('Invalid response from get_allow_lan', null);
+ }
+ }
+
+ async connectTunnel(): Promise<void> {
+ await this._transport.send('connect');
+ }
+
+ async disconnectTunnel(): Promise<void> {
+ await this._transport.send('disconnect');
+ }
+
+ async getLocation(): Promise<Location> {
+ // send the IPC with 30s timeout since the backend will wait
+ // for a HTTP request before replying
+
+ const response = await this._transport.send('get_current_location', [], 30000);
+ try {
+ return validate(LocationSchema, response);
+ } catch (error) {
+ throw new ResponseParseError('Invalid response from get_current_location', error);
+ }
+ }
+
+ async getState(): Promise<BackendState> {
+ const response = await this._transport.send('get_state');
+ try {
+ return validate(BackendStateSchema, response);
+ } catch (error) {
+ throw new ResponseParseError('Invalid response from get_state', error);
+ }
+ }
+
+ subscribeStateListener(listener: (state: ?BackendState, error: ?Error) => void): Promise<void> {
+ return this._transport.subscribe('new_state', (payload) => {
+ try {
+ const newState = validate(BackendStateSchema, payload);
+ listener(newState, null);
+ } catch (error) {
+ listener(null, new ResponseParseError('Invalid payload from new_state', error));
+ }
+ });
+ }
+
+ async getAccountHistory(): Promise<Array<AccountToken>> {
+ const response = await this._transport.send('get_account_history');
+ try {
+ return validate(arrayOf(string), response);
+ } catch (error) {
+ throw new ResponseParseError('Invalid response from get_account_history', null);
+ }
+ }
+
+ async removeAccountFromHistory(accountToken: AccountToken): Promise<void> {
+ await this._transport.send('remove_account_from_history', accountToken);
+ }
+}
diff --git a/app/lib/ipc-facade.js b/app/lib/ipc-facade.js
deleted file mode 100644
index 103ef4d931..0000000000
--- a/app/lib/ipc-facade.js
+++ /dev/null
@@ -1,356 +0,0 @@
-// @flow
-
-import JsonRpcWs, { InvalidReply } from './jsonrpc-ws-ipc';
-import {
- object,
- maybe,
- string,
- number,
- boolean,
- enumeration,
- arrayOf,
- oneOf,
-} from 'validated/schema';
-import { validate } from 'validated/object';
-
-import type { Node as SchemaNode } from 'validated/schema';
-
-export type AccountData = { expiry: string };
-export type AccountToken = string;
-export type Ip = string;
-export type Location = {
- ip: Ip,
- country: string,
- city: ?string,
- latitude: number,
- longitude: number,
- mullvad_exit_ip: boolean,
-};
-const LocationSchema = object({
- ip: string,
- country: string,
- city: maybe(string),
- latitude: number,
- longitude: number,
- mullvad_exit_ip: boolean,
-});
-
-export type SecurityState = 'secured' | 'unsecured';
-export type BackendState = {
- state: SecurityState,
- target_state: SecurityState,
-};
-
-export type RelayProtocol = 'tcp' | 'udp';
-export type RelayLocation = {| city: [string, string] |} | {| country: string |};
-
-type OpenVpnParameters = {
- port: 'any' | { only: number },
- protocol: 'any' | { only: RelayProtocol },
-};
-
-type TunnelOptions<TOpenVpnParameters> = {
- openvpn: TOpenVpnParameters,
-};
-
-type RelaySettingsNormal<TTunnelOptions> = {
- location:
- | 'any'
- | {
- only: RelayLocation,
- },
- tunnel:
- | 'any'
- | {
- only: TTunnelOptions,
- },
-};
-
-// types describing the structure of RelaySettings
-export type RelaySettingsCustom = {
- host: string,
- tunnel: {
- openvpn: {
- port: number,
- protocol: RelayProtocol,
- },
- },
-};
-export type RelaySettings =
- | {|
- normal: RelaySettingsNormal<TunnelOptions<OpenVpnParameters>>,
- |}
- | {|
- custom_tunnel_endpoint: RelaySettingsCustom,
- |};
-
-// types describing the partial update of RelaySettings
-export type RelaySettingsNormalUpdate = $Shape<
- RelaySettingsNormal<TunnelOptions<$Shape<OpenVpnParameters>>>,
->;
-export type RelaySettingsUpdate =
- | {|
- normal: RelaySettingsNormalUpdate,
- |}
- | {|
- custom_tunnel_endpoint: RelaySettingsCustom,
- |};
-
-const constraint = <T>(constraintValue: SchemaNode<T>) =>
- oneOf(
- string,
- object({
- only: constraintValue,
- }),
- );
-
-const RelaySettingsSchema = oneOf(
- object({
- normal: object({
- location: constraint(
- oneOf(
- object({
- city: arrayOf(string),
- }),
- object({
- country: string,
- }),
- ),
- ),
- tunnel: constraint(
- object({
- openvpn: object({
- port: constraint(number),
- protocol: constraint(enumeration('udp', 'tcp')),
- }),
- }),
- ),
- }),
- }),
- object({
- custom_tunnel_endpoint: object({
- host: string,
- tunnel: object({
- openvpn: object({
- port: number,
- protocol: enumeration('udp', 'tcp'),
- }),
- }),
- }),
- }),
-);
-
-export type RelayList = {
- countries: Array<RelayListCountry>,
-};
-
-export type RelayListCountry = {
- name: string,
- code: string,
- cities: Array<RelayListCity>,
-};
-
-export type RelayListCity = {
- name: string,
- code: string,
- latitude: number,
- longitude: number,
- has_active_relays: boolean,
-};
-
-const RelayListSchema = object({
- countries: arrayOf(
- object({
- name: string,
- code: string,
- cities: arrayOf(
- object({
- name: string,
- code: string,
- latitude: number,
- longitude: number,
- has_active_relays: boolean,
- }),
- ),
- }),
- ),
-});
-
-export interface IpcFacade {
- setConnectionString(string): void;
- getAccountData(AccountToken): Promise<AccountData>;
- getRelayLocations(): Promise<RelayList>;
- getAccount(): Promise<?AccountToken>;
- setAccount(accountToken: ?AccountToken): Promise<void>;
- updateRelaySettings(RelaySettingsUpdate): Promise<void>;
- getRelaySettings(): Promise<RelaySettings>;
- setAllowLan(boolean): Promise<void>;
- getAllowLan(): Promise<boolean>;
- connect(): Promise<void>;
- disconnect(): Promise<void>;
- getLocation(): Promise<Location>;
- getState(): Promise<BackendState>;
- registerStateListener((BackendState) => void): void;
- setCloseConnectionHandler(() => void): void;
- authenticate(sharedSecret: string): Promise<void>;
- getAccountHistory(): Promise<Array<AccountToken>>;
- removeAccountFromHistory(accountToken: AccountToken): Promise<void>;
-}
-
-export class RealIpc implements IpcFacade {
- _ipc: JsonRpcWs;
-
- constructor(connectionString: string) {
- this._ipc = new JsonRpcWs(connectionString);
- }
-
- setConnectionString(str: string) {
- this._ipc.setConnectionString(str);
- }
-
- getAccountData(accountToken: AccountToken): Promise<AccountData> {
- // send the IPC with 30s timeout since the backend will wait
- // for a HTTP request before replying
-
- return this._ipc.send('get_account_data', accountToken, 30000).then((raw) => {
- if (typeof raw === 'object' && raw && raw.expiry) {
- return raw;
- } else {
- throw new InvalidReply(raw, 'Expected an object with expiry');
- }
- });
- }
-
- async getRelayLocations(): Promise<RelayList> {
- const raw = await this._ipc.send('get_relay_locations');
- try {
- const validated: any = validate(RelayListSchema, raw);
- return (validated: RelayList);
- } catch (e) {
- throw new InvalidReply(raw, e);
- }
- }
-
- getAccount(): Promise<?AccountToken> {
- return this._ipc.send('get_account').then((raw) => {
- if (raw === undefined || raw === null || typeof raw === 'string') {
- return raw;
- } else {
- throw new InvalidReply(raw);
- }
- });
- }
-
- setAccount(accountToken: ?AccountToken): Promise<void> {
- return this._ipc.send('set_account', accountToken).then(this._ignoreResponse);
- }
-
- _ignoreResponse(_response: mixed): void {
- return;
- }
-
- updateRelaySettings(relaySettings: RelaySettingsUpdate): Promise<void> {
- return this._ipc.send('update_relay_settings', [relaySettings]).then(this._ignoreResponse);
- }
-
- getRelaySettings(): Promise<RelaySettings> {
- return this._ipc.send('get_relay_settings').then((raw) => {
- try {
- const validated: any = validate(RelaySettingsSchema, raw);
- return (validated: RelaySettings);
- } catch (e) {
- throw new InvalidReply(raw, e);
- }
- });
- }
-
- setAllowLan(allowLan: boolean): Promise<void> {
- return this._ipc.send('set_allow_lan', [allowLan]).then(this._ignoreResponse);
- }
-
- async getAllowLan(): Promise<boolean> {
- const raw = await this._ipc.send('get_allow_lan');
- if (typeof raw === 'boolean') {
- return raw;
- } else {
- throw new InvalidReply(raw, 'Expected a boolean');
- }
- }
-
- connect(): Promise<void> {
- return this._ipc.send('connect').then(this._ignoreResponse);
- }
-
- disconnect(): Promise<void> {
- return this._ipc.send('disconnect').then(this._ignoreResponse);
- }
-
- getLocation(): Promise<Location> {
- // send the IPC with 30s timeout since the backend will wait
- // for a HTTP request before replying
-
- return this._ipc.send('get_current_location', [], 30000).then((raw) => {
- try {
- const validated: any = validate(LocationSchema, raw);
- return (validated: Location);
- } catch (e) {
- throw new InvalidReply(raw, e);
- }
- });
- }
-
- getState(): Promise<BackendState> {
- return this._ipc.send('get_state').then((raw) => {
- return this._parseBackendState(raw);
- });
- }
-
- _parseBackendState(raw: mixed): BackendState {
- if (raw && raw.state && raw.target_state) {
- const uncheckedRaw: any = raw;
-
- const states: Array<SecurityState> = ['secured', 'unsecured'];
- const correctState = states.includes(uncheckedRaw.state);
- const correctTargetState = states.includes(uncheckedRaw.target_state);
-
- if (!correctState || !correctTargetState) {
- throw new InvalidReply(raw);
- }
-
- return (uncheckedRaw: BackendState);
- } else {
- throw new InvalidReply(raw);
- }
- }
-
- registerStateListener(listener: (BackendState) => void) {
- this._ipc.on('new_state', (rawEvent) => {
- const parsedEvent: BackendState = this._parseBackendState(rawEvent);
-
- listener(parsedEvent);
- });
- }
-
- setCloseConnectionHandler(handler: () => void) {
- this._ipc.setCloseConnectionHandler(handler);
- }
-
- authenticate(sharedSecret: string): Promise<void> {
- return this._ipc.send('auth', sharedSecret).then(this._ignoreResponse);
- }
-
- getAccountHistory(): Promise<Array<AccountToken>> {
- return this._ipc.send('get_account_history').then((raw) => {
- if (Array.isArray(raw) && raw.every((i) => typeof i === 'string')) {
- const checked: any = raw;
- return (checked: Array<AccountToken>);
- } else {
- throw new InvalidReply(raw, 'Expected an array of strings');
- }
- });
- }
-
- removeAccountFromHistory(accountToken: AccountToken): Promise<void> {
- return this._ipc.send('remove_account_from_history', accountToken).then(this._ignoreResponse);
- }
-}
diff --git a/app/lib/jsonrpc-transport.js b/app/lib/jsonrpc-transport.js
new file mode 100644
index 0000000000..2fa98df988
--- /dev/null
+++ b/app/lib/jsonrpc-transport.js
@@ -0,0 +1,326 @@
+// @flow
+
+import { EventEmitter } from 'events';
+import jsonrpc from 'jsonrpc-lite';
+import uuid from 'uuid';
+import { log } from '../lib/platform';
+
+export type UnansweredRequest = {
+ resolve: (mixed) => void,
+ reject: (mixed) => void,
+ timerId: TimeoutID,
+ message: Object,
+};
+
+export type JsonRpcErrorResponse = {
+ type: 'error',
+ payload: {
+ id: string,
+ error: {
+ code: number,
+ message: string,
+ },
+ },
+};
+export type JsonRpcNotification = {
+ type: 'notification',
+ payload: {
+ method: string,
+ params: {
+ subscription: string,
+ result: mixed,
+ },
+ },
+};
+export type JsonRpcSuccess = {
+ type: 'success',
+ payload: {
+ id: string,
+ result: mixed,
+ },
+};
+export type JsonRpcMessage = JsonRpcErrorResponse | JsonRpcNotification | JsonRpcSuccess;
+
+export class RemoteError extends Error {
+ _code: number;
+ _details: string;
+
+ constructor(code: number, details: string) {
+ super(`Remote JSON-RPC error ${code}: ${details}`);
+ this._code = code;
+ this._details = details;
+ }
+
+ get code(): number {
+ return this._code;
+ }
+
+ get details(): string {
+ return this._details;
+ }
+}
+
+export class TimeOutError extends Error {
+ _jsonRpcMessage: Object;
+
+ constructor(jsonRpcMessage: Object) {
+ super('Request timed out');
+
+ this._jsonRpcMessage = jsonRpcMessage;
+ }
+
+ get jsonRpcMessage(): Object {
+ return this._jsonRpcMessage;
+ }
+}
+
+export class SubscriptionError extends Error {
+ _reply: mixed;
+
+ constructor(message: string, reply: mixed) {
+ const replyString = JSON.stringify(reply);
+
+ super(`${message}: ${replyString}`);
+
+ this._reply = reply;
+ }
+
+ get reply(): mixed {
+ return this._reply;
+ }
+}
+
+export class ConnectionError extends Error {
+ _code: number;
+
+ constructor(code: number) {
+ super(ConnectionError.reason(code));
+ this._code = code;
+ }
+
+ get code(): number {
+ return this._code;
+ }
+
+ static reason(code: number): string {
+ switch (code) {
+ case 1006:
+ return 'Abnormal closure';
+ case 1011:
+ return 'Internal error';
+ case 1012:
+ return 'Service restart';
+ case 1014:
+ return 'Bad gateway';
+ default:
+ return `Unknown (${code})`;
+ }
+ }
+}
+
+const DEFAULT_TIMEOUT_MILLIS = 5000;
+
+export default class JsonRpcTransport extends EventEmitter {
+ _unansweredRequests: Map<string, UnansweredRequest> = new Map();
+ _subscriptions: Map<string | number, (mixed) => void> = new Map();
+ _websocket: ?WebSocket;
+ _websocketFactory: (string) => WebSocket;
+
+ constructor(websocketFactory: ?(string) => WebSocket) {
+ super();
+ this._websocketFactory =
+ websocketFactory || ((connectionString) => new WebSocket(connectionString));
+ }
+
+ /// Connect websocket
+ connect(connectionString: string) {
+ this.disconnect();
+
+ log.info('Connecting to websocket', connectionString);
+
+ const websocket = this._websocketFactory(connectionString);
+
+ websocket.onopen = () => {
+ log.info('Websocket is connected');
+ this.emit('open');
+ };
+
+ websocket.onmessage = (event) => {
+ const data = event.data;
+ if (typeof data === 'string') {
+ this._onMessage(data);
+ } else {
+ log.error('Got invalid reply from the server', event);
+ }
+ };
+
+ websocket.onclose = (event) => {
+ log.info(`The websocket connection closed with code: ${event.code}`);
+
+ // Remove all subscriptions since they are connection based
+ this._subscriptions.clear();
+
+ // 1000 is a code used for normal connection closure.
+ const connectionError = event.code === 1000 ? null : new ConnectionError(event.code);
+
+ this.emit('close', connectionError);
+ };
+
+ this._websocket = websocket;
+ }
+
+ disconnect() {
+ if (this._websocket) {
+ this._websocket.close();
+ this._websocket = null;
+ }
+ }
+
+ async subscribe(event: string, listener: (mixed) => void): Promise<*> {
+ log.silly(`Adding a listener to ${event}`);
+
+ try {
+ const subscriptionId = await this.send(`${event}_subscribe`);
+ if (typeof subscriptionId === 'string' || typeof subscriptionId === 'number') {
+ this._subscriptions.set(subscriptionId, listener);
+ } else {
+ throw new SubscriptionError(
+ 'The subscription id was not a string or a number',
+ subscriptionId,
+ );
+ }
+ } catch (e) {
+ log.error(`Failed adding listener to ${event}: ${e.message}`);
+ throw e;
+ }
+ }
+
+ async send(
+ action: string,
+ data: mixed,
+ timeout: number = DEFAULT_TIMEOUT_MILLIS,
+ ): Promise<mixed> {
+ let socket: WebSocket;
+ try {
+ socket = await this._getWebSocket();
+ } catch (error) {
+ throw error;
+ }
+
+ return new Promise((resolve, reject) => {
+ const id = uuid.v4();
+ const payload = this._prepareParams(data);
+ const timerId = setTimeout(() => this._onTimeout(id), timeout);
+ const message = jsonrpc.request(id, action, payload);
+ this._unansweredRequests.set(id, {
+ resolve,
+ reject,
+ timerId,
+ message,
+ });
+
+ try {
+ log.silly('Sending message', id, action);
+ socket.send(JSON.stringify(message));
+ } catch (error) {
+ log.error(`Failed sending RPC message "${action}": ${error.message}`);
+ throw error;
+ }
+ });
+ }
+
+ _prepareParams(data: mixed): Array<mixed> | Object {
+ // JSONRPC only accepts arrays and objects as params, but
+ // this isn't very nice to use, so this method wraps other
+ // types in an array. The choice of array is based on try-and-error
+
+ if (data === undefined) {
+ return [];
+ } else if (data === null) {
+ return [null];
+ } else if (Array.isArray(data) || typeof data === 'object') {
+ return data;
+ } else {
+ return [data];
+ }
+ }
+
+ _getWebSocket(): Promise<WebSocket> {
+ if (this._websocket && this._websocket.readyState === 1) {
+ return Promise.resolve(this._websocket);
+ } else {
+ return new Promise((resolve, reject) => {
+ log.debug('Waiting for websocket to connect');
+
+ this.once('open', () => {
+ const ws = this._websocket;
+ if (ws) {
+ resolve(ws);
+ } else {
+ reject(new Error('Internal error'));
+ }
+ });
+ });
+ }
+ }
+
+ _onTimeout(requestId) {
+ const request = this._unansweredRequests.get(requestId);
+
+ this._unansweredRequests.delete(requestId);
+
+ if (request) {
+ log.warn(`Request ${requestId} timed out: `, request.message);
+ request.reject(new TimeOutError(request.message));
+ } else {
+ log.warn(`Request ${requestId} timed out but it seems to already have been answered`);
+ }
+ }
+
+ _onMessage(message: string) {
+ const result = jsonrpc.parse(message);
+ const messages = Array.isArray(result) ? result : [result];
+
+ for (const message of messages) {
+ if (message.type === 'notification') {
+ this._onNotification(message);
+ } else {
+ this._onReply(message);
+ }
+ }
+ }
+
+ _onNotification(message: JsonRpcNotification) {
+ const subscriptionId = message.payload.params.subscription;
+ const listener = this._subscriptions.get(subscriptionId);
+
+ if (listener) {
+ log.silly('Got notification', message.payload.method, message.payload.params.result);
+ listener(message.payload.params.result);
+ } else {
+ log.warn('Got notification for', message.payload.method, 'but no one is listening for it');
+ }
+ }
+
+ _onReply(message: JsonRpcErrorResponse | JsonRpcSuccess) {
+ const id = message.payload.id;
+ const request = this._unansweredRequests.get(id);
+ this._unansweredRequests.delete(id);
+
+ if (request) {
+ log.silly('Got answer to', id, message.type);
+
+ clearTimeout(request.timerId);
+
+ if (message.type === 'error') {
+ const error = message.payload.error;
+ request.reject(new RemoteError(error.code, error.message));
+ } else {
+ const reply = message.payload.result;
+ request.resolve(reply);
+ }
+ } else {
+ log.warn(`Got reply to ${id} but no one was waiting for it`);
+ }
+ }
+}
diff --git a/app/lib/jsonrpc-ws-ipc.js b/app/lib/jsonrpc-ws-ipc.js
deleted file mode 100644
index 84dec96c77..0000000000
--- a/app/lib/jsonrpc-ws-ipc.js
+++ /dev/null
@@ -1,312 +0,0 @@
-// @flow
-
-import jsonrpc from 'jsonrpc-lite';
-import uuid from 'uuid';
-import { log } from '../lib/platform';
-
-export type UnansweredRequest = {
- resolve: (mixed) => void,
- reject: (mixed) => void,
- timerId: TimeoutID,
- message: Object,
-};
-
-export type JsonRpcErrorResponse = {
- type: 'error',
- payload: {
- id: string,
- error: {
- code: number,
- message: string,
- },
- },
-};
-export type JsonRpcNotification = {
- type: 'notification',
- payload: {
- method: string,
- params: {
- subscription: string,
- result: mixed,
- },
- },
-};
-export type JsonRpcSuccess = {
- type: 'success',
- payload: {
- id: string,
- result: mixed,
- },
-};
-export type JsonRpcMessage = JsonRpcErrorResponse | JsonRpcNotification | JsonRpcSuccess;
-
-export class JsonRpcError extends Error {
- _code: number;
- _details: string;
-
- constructor(code: number, details: string) {
- super(`Remote JSON-RPC error ${code}: ${details}`);
- this._code = code;
- this._details = details;
- }
-
- get code(): number {
- return this._code;
- }
-
- get details(): string {
- return this._details;
- }
-}
-
-export class TimeOutError extends Error {
- jsonRpcMessage: Object;
-
- constructor(jsonRpcMessage: Object) {
- super('Request timed out');
- this.name = 'TimeOutError';
- this.jsonRpcMessage = jsonRpcMessage;
- }
-}
-
-export class InvalidReply extends Error {
- reply: mixed;
-
- constructor(reply: mixed, msg: ?string) {
- super(msg);
- this.name = 'InvalidReply';
- this.reply = reply;
-
- if (msg) {
- this.message = msg + ' - ';
- }
- this.message += JSON.stringify(reply);
- }
-}
-
-const DEFAULT_TIMEOUT_MILLIS = 5000;
-
-export default class Ipc {
- _connectionString: ?string;
- _onConnect: Array<{ resolve: () => void }>;
- _unansweredRequests: Map<string, UnansweredRequest>;
- _subscriptions: Map<string | number, (mixed) => void>;
- _websocket: WebSocket;
- _backoff: ReconnectionBackoff;
- _websocketFactory: (string) => WebSocket;
- _closeConnectionHandler: ?() => void;
-
- constructor(connectionString: string, websocketFactory: ?(string) => WebSocket) {
- this._connectionString = connectionString;
- this._onConnect = [];
- this._unansweredRequests = new Map();
- this._subscriptions = new Map();
- this._websocketFactory =
- websocketFactory || ((connectionString) => new WebSocket(connectionString));
-
- this._backoff = new ReconnectionBackoff();
- this._reconnect();
- }
-
- setConnectionString(str: string) {
- this._connectionString = str;
- }
-
- setCloseConnectionHandler(handler: ?() => void) {
- this._closeConnectionHandler = handler;
- }
-
- async on(event: string, listener: (mixed) => void): Promise<*> {
- log.silly(`Adding a listener to ${event}`);
- try {
- const subscriptionId = await this.send(`${event}_subscribe`);
- if (typeof subscriptionId === 'string' || typeof subscriptionId === 'number') {
- this._subscriptions.set(subscriptionId, listener);
- } else {
- throw new InvalidReply(subscriptionId, 'The subscription id was not a string or a number');
- }
- } catch (e) {
- log.error(`Failed adding listener to ${event}: ${e.message}`);
- }
- }
-
- send(action: string, data: mixed, timeout: number = DEFAULT_TIMEOUT_MILLIS): Promise<mixed> {
- return new Promise(async (resolve, reject) => {
- const id = uuid.v4();
-
- const params = this._prepareParams(data);
- const timerId = setTimeout(() => this._onTimeout(id), timeout);
- const jsonrpcMessage = jsonrpc.request(id, action, params);
- this._unansweredRequests.set(id, {
- resolve: resolve,
- reject: reject,
- timerId: timerId,
- message: jsonrpcMessage,
- });
-
- try {
- const ws = await this._getWebSocket();
- log.silly('Sending message', id, action);
- ws.send(jsonrpcMessage);
- } catch (e) {
- log.error(`Failed sending RPC message "${action}": ${e.message}`);
- reject(e);
- }
- });
- }
-
- _prepareParams(data: mixed): Array<mixed> | Object {
- // JSONRPC only accepts arrays and objects as params, but
- // this isn't very nice to use, so this method wraps other
- // types in an array. The choice of array is based on try-and-error
-
- if (data === undefined) {
- return [];
- } else if (data === null) {
- return [null];
- } else if (Array.isArray(data) || typeof data === 'object') {
- return data;
- } else {
- return [data];
- }
- }
-
- _getWebSocket(): Promise<WebSocket> {
- return new Promise((resolve) => {
- if (this._websocket && this._websocket.readyState === 1) {
- resolve(this._websocket);
- } else {
- log.debug('Waiting for websocket to connect');
- this._onConnect.push({
- resolve: () => resolve(this._websocket),
- });
- }
- });
- }
-
- _onTimeout(requestId) {
- const request = this._unansweredRequests.get(requestId);
- this._unansweredRequests.delete(requestId);
-
- if (!request) {
- log.warn(requestId, 'timed out but it seems to already have been answered');
- return;
- }
-
- log.warn(request.message, 'timed out');
- request.reject(new TimeOutError(request.message));
- }
-
- _onMessage(message: string) {
- const json = JSON.parse(message);
- const c = jsonrpc.parseObject(json);
-
- if (c.type === 'notification') {
- this._onNotification(c);
- } else {
- this._onReply(c);
- }
- }
-
- _onNotification(message: JsonRpcNotification) {
- const subscriptionId = message.payload.params.subscription;
- const listener = this._subscriptions.get(subscriptionId);
-
- if (listener) {
- log.silly('Got notification', message.payload.method, message.payload.params.result);
- listener(message.payload.params.result);
- } else {
- log.warn('Got notification for', message.payload.method, 'but no one is listening for it');
- }
- }
-
- _onReply(message: JsonRpcErrorResponse | JsonRpcSuccess) {
- const id = message.payload.id;
- const request = this._unansweredRequests.get(id);
- this._unansweredRequests.delete(id);
-
- if (!request) {
- log.warn('Got reply to', id, 'but no one was waiting for it');
- return;
- }
-
- log.silly('Got answer to', id, message.type);
-
- clearTimeout(request.timerId);
-
- if (message.type === 'error') {
- const error = message.payload.error;
- request.reject(new JsonRpcError(error.code, error.message));
- } else {
- const reply = message.payload.result;
- request.resolve(reply);
- }
- }
-
- _reconnect() {
- const connectionString = this._connectionString;
- if (!connectionString) return;
-
- log.info('Connecting to websocket', connectionString);
- this._websocket = this._websocketFactory(connectionString);
-
- this._websocket.onopen = () => {
- log.info('Websocket is connected');
- this._backoff.successfullyConnected();
-
- while (this._onConnect.length > 0) {
- this._onConnect.pop().resolve();
- }
- };
-
- this._websocket.onmessage = (evt) => {
- const data = evt.data;
- if (typeof data === 'string') {
- this._onMessage(data);
- } else {
- log.error('Got invalid reply from the server', evt);
- }
- };
-
- this._websocket.onclose = () => {
- if (this._closeConnectionHandler) {
- this._closeConnectionHandler();
- }
-
- const delay = this._backoff.getIncreasedBackoff();
- log.warn(
- 'The websocket connetion closed, attempting to reconnect it in',
- delay,
- 'milliseconds',
- );
- setTimeout(() => this._reconnect(), delay);
- };
- }
-}
-
-/*
- * Used to calculate the time to wait before reconnecting
- * the websocket.
- *
- * It uses a linear backoff function that goes from 500ms
- * to 3000ms
- */
-class ReconnectionBackoff {
- _attempt: number;
-
- constructor() {
- this._attempt = 0;
- }
-
- successfullyConnected() {
- this._attempt = 0;
- }
-
- getIncreasedBackoff() {
- if (this._attempt < 6) {
- this._attempt++;
- }
-
- return this._attempt * 500;
- }
-}
diff --git a/app/lib/platform.android.js b/app/lib/platform.android.js
index fe7fd533f2..1dc450b359 100644
--- a/app/lib/platform.android.js
+++ b/app/lib/platform.android.js
@@ -1,9 +1,14 @@
// @flow
import { BackHandler, Linking } from 'react-native';
import { MobileAppBridge } from 'NativeModules';
+import { version } from '../../package.json';
const log = console.log;
+const getAppVersion = () => {
+ return version;
+};
+
const exit = () => {
BackHandler.exitApp();
};
@@ -16,4 +21,4 @@ const openItem = (path: string) => {
MobileAppBridge.openItem(path);
};
-export { log, exit, openLink, openItem };
+export { log, exit, openLink, openItem, getAppVersion };
diff --git a/app/lib/platform.js b/app/lib/platform.js
index 2b078b8bb6..ecbd32a27a 100644
--- a/app/lib/platform.js
+++ b/app/lib/platform.js
@@ -4,6 +4,10 @@ import electronLog from 'electron-log';
const log = electronLog;
+const getAppVersion = () => {
+ return remote.app.getVersion();
+};
+
const exit = () => {
remote.app.quit();
};
@@ -16,4 +20,4 @@ const openItem = (path: string) => {
shell.openItem(path);
};
-export { log, exit, openLink, openItem };
+export { log, exit, openLink, openItem, getAppVersion };
diff --git a/app/lib/relay-settings-builder.js b/app/lib/relay-settings-builder.js
index 0386d1ef4f..5f6c279e3f 100644
--- a/app/lib/relay-settings-builder.js
+++ b/app/lib/relay-settings-builder.js
@@ -6,7 +6,7 @@ import type {
RelaySettingsUpdate,
RelaySettingsNormalUpdate,
RelaySettingsCustom,
-} from './ipc-facade';
+} from './daemon-rpc';
type LocationBuilder<Self> = {
country: (country: string) => Self,
diff --git a/app/lib/rpc-address-file.js b/app/lib/rpc-address-file.js
new file mode 100644
index 0000000000..6ff920b8df
--- /dev/null
+++ b/app/lib/rpc-address-file.js
@@ -0,0 +1,115 @@
+// @flow
+
+import fs from 'fs';
+import path from 'path';
+import { app } from 'electron';
+import { promisify } from 'util';
+import { getSystemTemporaryDirectory } from './tempdir';
+
+const fsReadFileAsync = promisify(fs.readFile);
+
+const POLL_INTERVAL = 200;
+
+export type RpcCredentials = {
+ connectionString: string,
+ sharedSecret: string,
+};
+
+export class RpcAddressFile {
+ _filePath = getRpcAddressFilePath();
+ _pollIntervalId: ?IntervalID;
+ _pollPromise: ?Promise<void>;
+
+ get filePath(): string {
+ return this._filePath;
+ }
+
+ waitUntilExists(): Promise<void> {
+ let promise = this._pollPromise;
+
+ if (!promise) {
+ promise = new Promise((resolve, _reject) => {
+ const timer = setInterval(() => {
+ fs.exists(this._filePath, (exists) => {
+ if (exists) {
+ clearInterval(timer);
+ resolve();
+
+ this._pollPromise = null;
+ }
+ });
+ }, POLL_INTERVAL);
+ });
+
+ this._pollPromise = promise;
+ }
+
+ return promise;
+ }
+
+ async parse(): Promise<RpcCredentials> {
+ const data = await fsReadFileAsync(this._filePath, 'utf8');
+ const [connectionString, sharedSecret] = data.split('\n', 2);
+
+ if (connectionString && sharedSecret !== undefined) {
+ return {
+ connectionString,
+ sharedSecret,
+ };
+ } else {
+ throw new Error('Cannot parse the RPC address file');
+ }
+ }
+
+ isTrusted() {
+ const filePath = this._filePath;
+ switch (process.platform) {
+ case 'win32':
+ return isOwnedByLocalSystem(filePath);
+ case 'darwin':
+ case 'linux':
+ return isOwnedAndOnlyWritableByRoot(filePath);
+ default:
+ throw new Error(`Unknown platform: ${process.platform}`);
+ }
+ }
+}
+
+function getRpcAddressFilePath() {
+ const rpcAddressFileName = '.mullvad_rpc_address';
+
+ switch (process.platform) {
+ case 'win32': {
+ // Windows: %ALLUSERSPROFILE%\{appname}
+ const programDataDirectory = process.env.ALLUSERSPROFILE;
+ if (programDataDirectory) {
+ const appDataDirectory = path.join(programDataDirectory, app.getName());
+ return path.join(appDataDirectory, rpcAddressFileName);
+ } else {
+ throw new Error('Missing %ALLUSERSPROFILE% environment variable');
+ }
+ }
+ default:
+ return path.join(getSystemTemporaryDirectory(), rpcAddressFileName);
+ }
+}
+
+function isOwnedAndOnlyWritableByRoot(path: string): boolean {
+ const stat = fs.statSync(path);
+ const isOwnedByRoot = stat.uid === 0;
+ const isOnlyWritableByOwner = (stat.mode & parseInt('022', 8)) === 0;
+
+ return isOwnedByRoot && isOnlyWritableByOwner;
+}
+
+function isOwnedByLocalSystem(path: string): boolean {
+ // $FlowFixMe: this module is only available on Windows
+ const winsec = require('windows-security');
+ const ownerSid = winsec.getFileOwnerSid(path, null);
+ const isWellKnownSid = winsec.isWellKnownSid(
+ ownerSid,
+ winsec.WellKnownSid.BuiltinAdministratorsSid,
+ );
+
+ return isWellKnownSid;
+}
diff --git a/app/lib/rpc-file-security.js b/app/lib/rpc-file-security.js
deleted file mode 100644
index fa88111c02..0000000000
--- a/app/lib/rpc-file-security.js
+++ /dev/null
@@ -1,36 +0,0 @@
-// @flow
-
-import fs from 'fs';
-
-export function canTrustRpcAddressFile(path: string): boolean {
- const platform = process.platform;
- switch (platform) {
- case 'win32':
- return isOwnedByLocalSystem(path);
- case 'darwin':
- case 'linux':
- return isOwnedAndOnlyWritableByRoot(path);
- default:
- throw new Error(`Unknown platform: ${platform}`);
- }
-}
-
-function isOwnedAndOnlyWritableByRoot(path: string): boolean {
- const stat = fs.statSync(path);
- const isOwnedByRoot = stat.uid === 0;
- const isOnlyWritableByOwner = (stat.mode & parseInt('022', 8)) === 0;
-
- return isOwnedByRoot && isOnlyWritableByOwner;
-}
-
-function isOwnedByLocalSystem(path: string): boolean {
- // $FlowFixMe: this module is only available on Windows
- const winsec = require('windows-security');
- const ownerSid = winsec.getFileOwnerSid(path, null);
- const isWellKnownSid = winsec.isWellKnownSid(
- ownerSid,
- winsec.WellKnownSid.BuiltinAdministratorsSid,
- );
-
- return isWellKnownSid;
-}
diff --git a/app/main.js b/app/main.js
index 64c341dc60..6199b0517c 100644
--- a/app/main.js
+++ b/app/main.js
@@ -1,52 +1,17 @@
// @flow
import path from 'path';
-import fs from 'fs';
+import { execFile } from 'child_process';
import mkdirp from 'mkdirp';
-import { log } from './lib/platform';
+import uuid from 'uuid';
import { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from 'electron';
import TrayIconController from './tray-icon-controller';
import WindowController from './window-controller';
-import { version } from '../package.json';
-import { resolveBin } from './lib/proc';
-import { getSystemTemporaryDirectory } from './lib/tempdir';
-import { canTrustRpcAddressFile } from './lib/rpc-file-security';
-import { execFile } from 'child_process';
-import uuid from 'uuid';
-
+import { RpcAddressFile } from './lib/rpc-address-file';
import { ShutdownCoordinator } from './shutdown-handler';
+import { log } from './lib/platform';
+import { resolveBin } from './lib/proc';
import type { TrayIconType } from './tray-icon-controller';
-/*
-HOTFIX
-We had an issue importing this stuff from backend.js but
-since it's going away in one of already open PRs,
-there is no point in trying to make it any nicer,
-
-Hence duplicating this piece in here
-TODO: Remove during merge
- */
-export type IpcCredentials = {
- connectionString: string,
- sharedSecret: string,
-};
-export function parseIpcCredentials(data: string): ?IpcCredentials {
- const [connectionString, sharedSecret] = data.split('\n', 2);
- if (connectionString && sharedSecret !== undefined) {
- return {
- connectionString,
- sharedSecret,
- };
- } else {
- return null;
- }
-}
-
-/** / HOTFIX */
-
-// The name for application directory used for
-// scoping logs and user data in platform special folders
-const appDirectoryName = 'Mullvad VPN';
-
const ApplicationMain = {
_windowController: (null: ?WindowController),
_trayIconController: (null: ?TrayIconController),
@@ -59,11 +24,9 @@ const ApplicationMain = {
return;
}
- // Override userData path, i.e on macOS: ~/Library/Application Support/Mullvad VPN
- app.setPath('userData', path.join(app.getPath('appData'), appDirectoryName));
this._initLogging();
- log.info('Running version', version);
+ log.info(`Running version ${app.getVersion()}`);
app.on('ready', () => this._onReady());
app.on('window-all-closed', () => app.quit());
@@ -107,6 +70,8 @@ const ApplicationMain = {
log.transports.file.file = this._logFilePath;
}
+ log.debug(`Logging to ${this._logFilePath}`);
+
// create log folder
mkdirp.sync(logDirectory);
},
@@ -119,7 +84,7 @@ const ApplicationMain = {
switch (process.platform) {
case 'darwin':
// macOS: ~/Library/Logs/{appname}
- return path.join(app.getPath('home'), 'Library/Logs', appDirectoryName);
+ return path.join(app.getPath('home'), 'Library/Logs', app.getName());
default:
// Windows: %APPDATA%\{appname}\logs
// Linux: ~/.config/{appname}/logs
@@ -162,8 +127,43 @@ const ApplicationMain = {
},
_registerIpcListeners() {
- ipcMain.on('on-browser-window-ready', () => {
- this._pollConnectionInfoFile();
+ ipcMain.on('discover-daemon-connection', async (event) => {
+ const addressFile = new RpcAddressFile();
+
+ log.debug(`Waiting for RPC address file: "${addressFile.filePath}"`);
+
+ try {
+ await addressFile.waitUntilExists();
+ } catch (error) {
+ log.error(`Cannot finish polling the RPC address file: ${error.message}`);
+ return;
+ }
+
+ try {
+ if (!addressFile.isTrusted()) {
+ log.error(`Cannot verify the credibility of RPC address file`);
+ return;
+ }
+ } catch (error) {
+ log.error(`An error occurred during the credibility check: ${error.message}`);
+ return;
+ }
+
+ // There is a race condition here where the owner and permissions of
+ // the file can change in the time between we validate the owner and
+ // permissions and read the contents of the file. We deem the chance
+ // of that to be small enough to ignore.
+
+ try {
+ const credentials = await addressFile.parse();
+
+ log.debug('Read RPC connection info', credentials.connectionString);
+
+ event.sender.send('daemon-connection-ready', credentials);
+ } catch (error) {
+ log.error(`Cannot parse the RPC address file: ${error.message}`);
+ return;
+ }
});
ipcMain.on('show-window', () => {
@@ -249,84 +249,6 @@ const ApplicationMain = {
);
},
- _getRpcAddressFilePath() {
- const rpcAddressFileName = '.mullvad_rpc_address';
-
- switch (process.platform) {
- case 'win32': {
- // Windows: %ALLUSERSPROFILE%\{appname}
- const programDataDirectory = process.env.ALLUSERSPROFILE;
- if (programDataDirectory) {
- const appDataDirectory = path.join(programDataDirectory, appDirectoryName);
- return path.join(appDataDirectory, rpcAddressFileName);
- } else {
- throw new Error('Missing %ALLUSERSPROFILE% environment variable');
- }
- }
- default:
- return path.join(getSystemTemporaryDirectory(), rpcAddressFileName);
- }
- },
-
- _pollConnectionInfoFile() {
- if (this._connectionFilePollInterval) {
- log.warn(
- 'Attempted to start polling for the RPC connection info file while another polling was already running',
- );
- return;
- }
-
- const pollIntervalMs = 200;
- const rpcAddressFile = this._getRpcAddressFilePath();
-
- this._connectionFilePollInterval = setInterval(() => {
- if (fs.existsSync(rpcAddressFile)) {
- if (this._connectionFilePollInterval) {
- clearInterval(this._connectionFilePollInterval);
- this._connectionFilePollInterval = null;
- }
-
- this._sendDaemonConnectionInfo(rpcAddressFile);
- }
- }, pollIntervalMs);
- },
-
- _sendDaemonConnectionInfo(rpcAddressFile: string) {
- log.debug(`Reading the ipc connection info from "${rpcAddressFile}"`);
-
- try {
- if (!canTrustRpcAddressFile(rpcAddressFile)) {
- log.error(`Not trusting the contents of "${rpcAddressFile}".`);
- return;
- }
- } catch (e) {
- log.error(`Cannot verify the credibility of RPC address file: ${e.message}`);
- return;
- }
-
- // There is a race condition here where the owner and permissions of
- // the file can change in the time between we validate the owner and
- // permissions and read the contents of the file. We deem the chance
- // of that to be small enough to ignore.
-
- fs.readFile(rpcAddressFile, 'utf8', (err, data) => {
- if (err) {
- return log.error('Could not find backend connection info', err);
- }
-
- const credentials = parseIpcCredentials(data);
- if (credentials) {
- log.debug('Read IPC connection info', credentials.connectionString);
- const windowController = this._windowController;
- if (windowController) {
- windowController.window.webContents.send('backend-info', { credentials });
- }
- } else {
- log.error('Could not parse IPC credentials.');
- }
- });
- },
-
async _installDevTools() {
const installer = require('electron-devtools-installer');
const extensions = ['REACT_DEVELOPER_TOOLS', 'REDUX_DEVTOOLS'];
diff --git a/app/redux/account/actions.js b/app/redux/account/actions.js
index c778b111fd..df25c1e823 100644
--- a/app/redux/account/actions.js
+++ b/app/redux/account/actions.js
@@ -1,6 +1,6 @@
// @flow
-import type { AccountToken } from '../../lib/ipc-facade';
+import type { AccountToken } from '../../lib/daemon-rpc';
import type { Backend } from '../../lib/backend';
type StartLoginAction = {
diff --git a/app/redux/account/reducers.js b/app/redux/account/reducers.js
index 094864c9a8..dbd854a6c0 100644
--- a/app/redux/account/reducers.js
+++ b/app/redux/account/reducers.js
@@ -1,7 +1,7 @@
// @flow
import type { ReduxAction } from '../store';
-import type { AccountToken } from '../../lib/ipc-facade';
+import type { AccountToken } from '../../lib/daemon-rpc';
export type LoginState = 'none' | 'logging in' | 'failed' | 'ok';
export type AccountReduxState = {
diff --git a/app/redux/connection/actions.js b/app/redux/connection/actions.js
index b4f1df09be..bb155aa485 100644
--- a/app/redux/connection/actions.js
+++ b/app/redux/connection/actions.js
@@ -1,13 +1,9 @@
// @flow
import { Clipboard } from 'reactxp';
-
-import type { Backend } from '../../lib/backend';
import type { ReduxThunk } from '../store';
-import type { Ip } from '../../lib/ipc-facade';
+import type { Ip } from '../../lib/daemon-rpc';
-const connect = (backend: Backend): ReduxThunk => () => backend.connect();
-const disconnect = (backend: Backend) => () => backend.disconnect();
const copyIPAddress = (): ReduxThunk => {
return (_, getState) => {
const ip = getState().connection.ip;
@@ -93,8 +89,6 @@ function offline(): OfflineAction {
}
export default {
- connect,
- disconnect,
copyIPAddress,
newLocation,
connecting,
diff --git a/app/redux/connection/reducers.js b/app/redux/connection/reducers.js
index a50fbf0757..c75d0f2a8f 100644
--- a/app/redux/connection/reducers.js
+++ b/app/redux/connection/reducers.js
@@ -1,7 +1,7 @@
// @flow
import type { ReduxAction } from '../store';
-import type { Ip } from '../../lib/ipc-facade';
+import type { Ip } from '../../lib/daemon-rpc';
export type ConnectionState = 'disconnected' | 'connecting' | 'connected';
export type ConnectionReduxState = {
diff --git a/app/redux/settings/reducers.js b/app/redux/settings/reducers.js
index f559c1f725..4a25bf9502 100644
--- a/app/redux/settings/reducers.js
+++ b/app/redux/settings/reducers.js
@@ -1,7 +1,7 @@
// @flow
import type { ReduxAction } from '../store';
-import type { RelayProtocol, RelayLocation } from '../../lib/ipc-facade';
+import type { RelayProtocol, RelayLocation } from '../../lib/daemon-rpc';
export type RelaySettingsRedux =
| {|
diff --git a/package.json b/package.json
index 3c2a6182de..e8ede62cc7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
{
"name": "mullvad-vpn",
"version": "2018.2.0-beta1",
+ "productName": "Mullvad VPN",
"description": "Mullvad VPN client",
"main": "init.js",
"author": {
@@ -61,9 +62,9 @@
"eslint-plugin-react": "^7.9.1",
"flow-bin": "^0.66.0",
"flow-typed": "^2.4.0",
+ "mock-socket": "^7.1.0",
"npm-run-all": "^4.0.1",
"prettier": "1.13.3",
- "redux-mock-store": "^1.3.0",
"rimraf": "^2.5.4"
},
"scripts": {
diff --git a/test/auth.spec.js b/test/auth.spec.js
deleted file mode 100644
index e6b2c25486..0000000000
--- a/test/auth.spec.js
+++ /dev/null
@@ -1,37 +0,0 @@
-// @flow
-
-import { setupIpcAndStore, setupBackendAndStore } from './helpers/ipc-helpers';
-import { IpcChain } from './helpers/IpcChain';
-import { Backend } from '../app/lib/backend';
-
-describe('authentication', () => {
- it('authenticates before ipc call if unauthenticated', (done) => {
- const { store, mockIpc } = setupIpcAndStore();
-
- const chain = new IpcChain(mockIpc);
- chain.onSuccessOrFailure(done);
- chain.expect('authenticate').withInputValidation((secret) => {
- expect(secret).to.equal(credentials.sharedSecret);
- });
- chain.expect('connect');
-
- const credentials = {
- sharedSecret: '',
- connectionString: '',
- };
- const backend = new Backend(store, credentials, mockIpc);
- backend.connect();
- });
-
- it('reauthenticates on reconnect', async () => {
- const { mockIpc, backend } = setupBackendAndStore();
-
- mockIpc.authenticate = spy(mockIpc.authenticate);
- mockIpc.killWebSocket();
-
- expect(mockIpc.authenticate).to.not.have.been.called();
-
- await backend.connect();
- expect(mockIpc.authenticate).to.have.been.called.once;
- });
-});
diff --git a/test/autologin.spec.js b/test/autologin.spec.js
deleted file mode 100644
index 60c5cc5bde..0000000000
--- a/test/autologin.spec.js
+++ /dev/null
@@ -1,126 +0,0 @@
-// @flow
-
-import { setupBackendAndStore, setupBackendAndMockStore, getLocation } from './helpers/ipc-helpers';
-import { IpcChain } from './helpers/IpcChain';
-
-describe('autologin', () => {
- it('should send get_account then get_account_data if an account is set', (done) => {
- const { mockIpc, backend } = setupBackendAndStore();
-
- const randomAccountToken = '12345';
-
- const chain = new IpcChain(mockIpc);
- chain.expect('getAccount').withReturnValue(randomAccountToken);
-
- chain.expect('getAccountData').withInputValidation((num) => {
- expect(num).to.equal(randomAccountToken);
- });
-
- chain.onSuccessOrFailure(done);
-
- backend.autologin();
- });
-
- it('should redirect to the login page if no account is set', () => {
- const { store, backend, mockIpc } = setupBackendAndMockStore();
-
- mockIpc.getAccount = () => new Promise((_, reject) => reject('NO_ACCOUNT'));
-
- return backend
- .autologin()
- .then(() => {
- expect(getLocation(store)).to.equal('/');
- })
- .catch((e) => {
- if (e !== 'NO_ACCOUNT') {
- throw e;
- }
- });
- });
-
- it('should redirect to the login page for non-existing accounts', () => {
- const { store, backend, mockIpc } = setupBackendAndMockStore();
-
- mockIpc.getAccount = () => new Promise((r) => r('123'));
- mockIpc.getAccountData = () => new Promise((_, reject) => reject('NO_ACCOUNT'));
-
- return backend
- .autologin()
- .then(() => {
- expect(getLocation(store)).to.equal('/');
- })
- .catch((e) => {
- if (e !== 'NO_ACCOUNT') {
- throw e;
- }
- });
- });
-
- it('should mark the state as not logged in if no account is set', () => {
- const { store, backend, mockIpc } = setupBackendAndStore();
-
- mockIpc.getAccount = () => Promise.resolve(null);
-
- return backend
- .autologin()
- .catch(() => {}) // ignore errors
- .then(() => {
- const state = store.getState().account;
-
- expect(state.status).to.equal('none');
- expect(state.accountToken).to.be.null;
- expect(state.error).to.be.null;
- });
- });
-
- it('should mark the state as not logged in for non-existing accounts', () => {
- const { store, backend, mockIpc } = setupBackendAndStore();
-
- mockIpc.getAccount = () => new Promise((r) => r('123'));
- mockIpc.getAccountData = () => new Promise((_, reject) => reject('NO ACCOUNT'));
-
- return backend
- .autologin()
- .catch(() => {}) // ignore errors
- .then(() => {
- const state = store.getState().account;
-
- expect(state.status).to.equal('none');
- expect(state.error).to.be.null;
- });
- });
-
- it('should put the account data in the state for existing accounts', () => {
- const { store, backend, mockIpc } = setupBackendAndStore();
- mockIpc.getAccount = () => new Promise((r) => r('123'));
- mockIpc.getAccountData = () =>
- new Promise((r) =>
- r({
- expiry: '2001-01-01T00:00:00Z',
- }),
- );
-
- return backend.autologin().then(() => {
- const state = store.getState().account;
- expect(state.status).to.equal('ok');
- expect(state.accountToken).to.equal('123');
- expect(state.expiry).to.equal('2001-01-01T00:00:00Z');
- });
- });
-
- it('should redirect to /connect for existing accounts', () => {
- const { store, backend, mockIpc } = setupBackendAndMockStore();
-
- mockIpc.getAccount = () => new Promise((r) => r('123'));
- mockIpc.getAccountData = () =>
- new Promise((r) =>
- r({
- expiry: '2001-01-01T00:00:00Z',
- }),
- );
-
- return backend.autologin().then(() => {
- expect(getLocation(store)).to.equal('/connect');
- });
- });
-});
diff --git a/test/components/Login.spec.js b/test/components/Login.spec.js
index ce7f347acf..76acbd6330 100644
--- a/test/components/Login.spec.js
+++ b/test/components/Login.spec.js
@@ -2,32 +2,18 @@
import * as React from 'react';
import { shallow } from 'enzyme';
-
import Login from '../../app/components/Login';
-import AccountInput from '../../app/components/AccountInput';
describe('components/Login', () => {
- it('notifies on the first change after failure', () => {
- const onFirstChange = spy();
- const props = {
- account: Object.assign({}, defaultAccount, {
- status: 'failed',
- }),
- onFirstChangeAfterFailure: onFirstChange,
- };
-
- const component = renderWithProps(props);
- const accountInput = component.find(AccountInput);
-
- accountInput.simulate('change', 'foo');
- expect(onFirstChange).to.have.been.called.once;
-
- accountInput.simulate('change', 'bar');
- expect(onFirstChange).to.have.been.called.once;
- });
-
it('does not show the footer when logging in', () => {
- const component = renderLoggingIn();
+ const component = shallow(
+ <Login
+ {...{
+ ...defaultProps,
+ loginState: 'logging in',
+ }}
+ />,
+ );
const visibleFooters = getComponent(component, 'footerVisibility true');
const invisibleFooters = getComponent(component, 'footerVisibility false');
expect(visibleFooters.length).to.equal(0);
@@ -35,7 +21,7 @@ describe('components/Login', () => {
});
it('shows the footer and account input when not logged in', () => {
- const component = renderNotLoggedIn();
+ const component = shallow(<Login {...defaultProps} />);
const visibleFooters = getComponent(component, 'footerVisibility true');
const invisibleFooters = getComponent(component, 'footerVisibility false');
expect(visibleFooters.length).to.equal(1);
@@ -44,7 +30,14 @@ describe('components/Login', () => {
});
it('does not show the footer nor account input when logged in', () => {
- const component = renderLoggedIn();
+ const component = shallow(
+ <Login
+ {...{
+ ...defaultProps,
+ loginState: 'ok',
+ }}
+ />,
+ );
const visibleFooters = getComponent(component, 'footerVisibility true');
const invisibleFooters = getComponent(component, 'footerVisibility false');
expect(visibleFooters.length).to.equal(0);
@@ -53,14 +46,12 @@ describe('components/Login', () => {
});
it('logs in with the entered account number when clicking the login icon', (done) => {
- const component = renderNotLoggedIn();
+ const component = shallow(<Login {...defaultProps} />);
component.setProps({
- account: Object.assign({}, defaultAccount, {
- accountToken: '12345',
- }),
- onLogin: (an) => {
+ accountToken: '12345',
+ login: (accountToken) => {
try {
- expect(an).to.equal('12345');
+ expect(accountToken).to.equal('12345');
done();
} catch (e) {
done(e);
@@ -72,54 +63,19 @@ describe('components/Login', () => {
});
});
-const defaultAccount = {
+const defaultProps = {
accountToken: null,
accountHistory: [],
- expiry: null,
- status: 'none',
- error: null,
+ loginError: null,
+ loginState: 'none',
+ openSettings: () => {},
+ openExternalLink: (_type) => {},
+ login: (_accountToken) => {},
+ resetLoginError: () => {},
+ updateAccountToken: (_accountToken) => {},
+ removeAccountTokenFromHistory: (_accountToken) => Promise.resolve(),
};
-const defaultProps = {
- account: defaultAccount,
- onLogin: () => {},
- onSettings: () => {},
- onChange: () => {},
- onFirstChangeAfterFailure: () => {},
- onExternalLink: () => {},
- onAccountTokenChange: (_accountToken) => {},
- onRemoveAccountTokenFromHistory: (_accountToken) => {},
-};
-
-function renderLoggedIn() {
- return renderWithProps({
- account: Object.assign(defaultAccount, {
- status: 'ok',
- }),
- });
-}
-
-function renderLoggingIn() {
- return renderWithProps({
- account: Object.assign(defaultAccount, {
- status: 'logging in',
- }),
- });
-}
-
-function renderNotLoggedIn() {
- return renderWithProps({
- account: Object.assign(defaultAccount, {
- status: 'none',
- }),
- });
-}
-
-function renderWithProps(customProps) {
- const props = Object.assign({}, defaultProps, customProps);
- return shallow(<Login {...props} />);
-}
-
function getComponent(container, testName) {
return container.findWhere((n) => n.prop('testName') === testName);
}
diff --git a/test/connect.spec.js b/test/connect.spec.js
deleted file mode 100644
index f1621dbeb6..0000000000
--- a/test/connect.spec.js
+++ /dev/null
@@ -1,62 +0,0 @@
-// @flow
-
-import connectionActions from '../app/redux/connection/actions';
-import { setupBackendAndStore, checkNextTick } from './helpers/ipc-helpers';
-
-describe('connect', () => {
- it("should set the connection state to 'disconnected' on failed attempts", (done) => {
- const { store, mockIpc, backend } = setupBackendAndStore();
-
- mockIpc.connect = () => new Promise((_, reject) => reject('Some error'));
-
- store.dispatch(connectionActions.connected());
-
- expect(store.getState().connection.status).not.to.equal('disconnected');
-
- store.dispatch(connectionActions.connect(backend));
-
- checkNextTick(() => {
- expect(store.getState().connection.status).to.equal('disconnected');
- }, done);
- });
-
- it('should update the state with the server address', () => {
- const { store, backend } = setupBackendAndStore();
-
- return backend.connect().then(() => {
- const state = store.getState().connection;
- expect(state.status).to.equal('connecting');
- });
- });
-
- it("should correctly deduce 'connected' from backend states", (done) => {
- const { store, mockIpc } = setupBackendAndStore();
-
- checkNextTick(() => {
- expect(store.getState().connection.status).not.to.equal('connected');
- mockIpc.sendNewState({ state: 'secured', target_state: 'secured' });
- expect(store.getState().connection.status).to.equal('connected');
- }, done);
- });
-
- it("should correctly deduce 'connecting' from backend states", (done) => {
- const { store, mockIpc } = setupBackendAndStore();
-
- checkNextTick(() => {
- expect(store.getState().connection.status).not.to.equal('connecting');
- mockIpc.sendNewState({ state: 'unsecured', target_state: 'secured' });
- expect(store.getState().connection.status).to.equal('connecting');
- }, done);
- });
-
- it("should correctly deduce 'disconnected' from backend states", (done) => {
- const { store, mockIpc } = setupBackendAndStore();
- store.dispatch(connectionActions.connected());
-
- checkNextTick(() => {
- expect(store.getState().connection.status).not.to.equal('disconnected');
- mockIpc.sendNewState({ state: 'unsecured', target_state: 'unsecured' });
- expect(store.getState().connection.status).to.equal('disconnected');
- }, done);
- });
-});
diff --git a/test/helpers/IpcChain.js b/test/helpers/IpcChain.js
deleted file mode 100644
index 1505621cc0..0000000000
--- a/test/helpers/IpcChain.js
+++ /dev/null
@@ -1,100 +0,0 @@
-// @flow
-
-import { check, failFast } from './ipc-helpers';
-
-export class IpcChain {
- _expectedCalls: Array<string>;
- _recordedCalls: Array<string>;
- _mockIpc: {};
- _done: (?Error) => void;
- _aborted: boolean;
-
- constructor(mockIpc: {}) {
- this._expectedCalls = [];
- this._recordedCalls = [];
- this._mockIpc = mockIpc;
- this._aborted = false;
- }
-
- expect<R>(ipcCall: string): StepBuilder<R> {
- const builder = new StepBuilder(ipcCall);
- this._expectedCalls.push(ipcCall);
- this._addStep(builder);
-
- return builder;
- }
-
- _addStep<R>(step: StepBuilder<R>) {
- this._mockIpc[step.ipcCall] = (...args: Array<mixed>) => {
- return new Promise((r) => this._stepPromiseCallback(step, r, args));
- };
- }
-
- _stepPromiseCallback<R>(step: StepBuilder<R>, resolve: (?R) => void, args: Array<mixed>) {
- if (this._aborted) {
- return;
- }
-
- this._registerCall(step.ipcCall);
-
- const inputValidation = step.inputValidation;
- if (inputValidation) {
- const failedInputValidation = failFast(() => {
- inputValidation(...args);
- }, this._done);
-
- if (failedInputValidation) {
- this._abort();
- return;
- }
- }
-
- if (this._isLastCall()) {
- this._ensureChainCalledCorrectly();
- }
-
- resolve(step.returnValue);
- }
-
- _abort() {
- this._aborted = true;
- }
-
- _isLastCall(): boolean {
- return this._recordedCalls.length === this._expectedCalls.length;
- }
-
- _ensureChainCalledCorrectly() {
- check(() => {
- expect(this._expectedCalls).to.deep.equal(this._recordedCalls);
- }, this._done);
- }
-
- _registerCall(ipcCall: string) {
- this._recordedCalls.push(ipcCall);
- }
-
- onSuccessOrFailure(done: (*) => void) {
- this._done = done;
- }
-}
-
-class StepBuilder<R> {
- ipcCall: string;
- inputValidation: ?(...args: Array<mixed>) => void;
- returnValue: ?R;
-
- constructor(ipcCall: string) {
- this.ipcCall = ipcCall;
- }
-
- withInputValidation(iv: (...args: Array<mixed>) => void): this {
- this.inputValidation = iv;
- return this;
- }
-
- withReturnValue(rv: R): this {
- this.returnValue = rv;
- return this;
- }
-}
diff --git a/test/helpers/ipc-helpers.js b/test/helpers/ipc-helpers.js
deleted file mode 100644
index 4466c06eb2..0000000000
--- a/test/helpers/ipc-helpers.js
+++ /dev/null
@@ -1,104 +0,0 @@
-// @flow
-
-import { Backend } from '../../app/lib/backend';
-import { newMockIpc } from '../mocks/ipc';
-import configureStore from '../../app/redux/store';
-import { createMemoryHistory } from 'history';
-import { mockStore } from '../mocks/redux';
-
-type DoneCallback = (?Error) => void;
-type Check = () => void;
-
-export function setupIpcAndStore() {
- const memoryHistory = createMemoryHistory();
- const store = configureStore(null, memoryHistory);
-
- const mockIpc = newMockIpc();
-
- return { store, mockIpc };
-}
-
-export function setupBackendAndStore() {
- const { store, mockIpc } = setupIpcAndStore();
-
- const credentials = {
- sharedSecret: '',
- connectionString: '',
- };
- const backend = new Backend(store, credentials, mockIpc);
-
- return { store, mockIpc, backend };
-}
-
-export function setupBackendAndMockStore() {
- const store = mockStore(_initialState());
- const mockIpc = newMockIpc();
- const credentials = {
- sharedSecret: '',
- connectionString: '',
- };
- const backend = new Backend(store, credentials, mockIpc);
- return { store, mockIpc, backend };
-}
-
-function _initialState() {
- const { store } = setupIpcAndStore();
- return store.getState();
-}
-
-// chai and async aren't the best of friends. To allow us
-// to get the assertion error in the output of failed async
-// tests we need to do this try-catch thing.
-export function check(fn: Check, done: DoneCallback) {
- try {
- fn();
- done();
- } catch (e) {
- done(e);
- }
-}
-
-// Sometimes with redux we cannot know when all reducers have
-// finished running. This function puts the check at the end
-// of the execution queue, hopefully resulting in the check being
-// run after the reducers are finished
-export function checkNextTick(fn: Check, done: DoneCallback) {
- setTimeout(() => {
- check(fn, done);
- }, 1);
-}
-
-// In async tests where we want to test a chain of IPC messages
-// we can only invoke `done` for the last message. This function
-// is for the intermediate messages.
-export function failFast(fn: Check, done: DoneCallback): boolean {
- try {
- fn();
- return false;
- } catch (e) {
- done(e);
- return true;
- }
-}
-export function failFastNextTick(fn: Check, done: DoneCallback) {
- setTimeout(() => {
- failFast(fn, done);
- }, 1);
-}
-
-type MockStore = {
- getActions: () => Array<{ type: string, payload: Object }>,
-};
-// Parses the action log to find out which URL we most recently navigated to
-// Note that this cannot be done with the real redux store, but rather must be
-// done with the mock store.
-export function getLocation(store: MockStore): ?string {
- const navigations = store
- .getActions()
- .filter((action) => action.type === '@@router/CALL_HISTORY_METHOD');
- if (navigations.length === 0) {
- return null;
- }
-
- return navigations[navigations.length - 1].payload.args[0];
-}
diff --git a/test/ipc.spec.js b/test/ipc.spec.js
deleted file mode 100644
index 1c8b19b5a6..0000000000
--- a/test/ipc.spec.js
+++ /dev/null
@@ -1,134 +0,0 @@
-// @flow
-
-import Ipc from '../app/lib/jsonrpc-ws-ipc';
-import jsonrpc from 'jsonrpc-lite';
-import type { JsonRpcMessage } from '../app/lib/jsonrpc-ws-ipc';
-
-describe('The IPC server', () => {
- it('should send as soon as the websocket connects', () => {
- const { ws, ipc } = setupIpc();
- ws.close();
-
- let sent = false;
- const p = ipc.send('hello').then(() => {
- expect(sent).to.be.true;
- });
-
- ws.on('hello', (msg) => {
- sent = true;
-
- ws.replyOk(msg.id);
- });
- ws.acceptConnection();
-
- return p;
- });
-
- it('should reject failed jsonrpc requests', () => {
- const { ws, ipc } = setupIpc();
- ws.on('WHAT_IS_THIS', (msg) => {
- ws.replyFail(msg.id, 'Method not found', -32601);
- });
-
- return ipc.send('WHAT_IS_THIS').catch((e) => {
- expect(e.code).to.equal(-32601);
- expect(e.message).to.contain('Method not found');
- });
- });
-
- it('should route reply to correct promise', () => {
- const { ws, ipc } = setupIpc();
-
- ws.on('a message', (msg) => ws.replyOk(msg.id, 'a reply'));
-
- const decoy = ipc
- .send('a decoy', [], 1)
- .then(() => {
- throw new Error('Should not be called');
- })
- .catch((e) => {
- if (e.name !== 'TimeOutError') {
- throw e;
- }
- });
- const message = ipc.send('a message', [], 1).then((reply) => expect(reply).to.equal('a reply'));
-
- return Promise.all([message, decoy]);
- });
-
- it('should timeout if no response is returned', () => {
- const { ipc } = setupIpc();
-
- return ipc.send('a message', [], 1).catch((e) => {
- expect(e.name).to.equal('TimeOutError');
- expect(e.message).to.contain('timed out');
- });
- });
-
- it('should route notifications', (done) => {
- const { ws, ipc } = setupIpc();
-
- const eventListener = (event) => {
- try {
- expect(event).to.equal('an event!');
- done();
- } catch (ex) {
- done(ex);
- }
- };
-
- ws.on('event_subscribe', (msg) => ws.replyOk(msg.id, 1));
- ipc
- .on('event', eventListener)
- .then(() => {
- ws.reply(jsonrpc.notification('event', { subscription: 1, result: 'an event!' }));
- })
- .catch((e) => done(e));
- });
-});
-
-function mockWebsocket() {
- const ws: any = {
- listeners: {},
- readyState: 1,
- };
-
- ws.on = (event, listener) => (ws.listeners[event] = listener);
- ws.send = (data) => {
- const listener = ws.listeners[data.method];
- if (listener) {
- listener(data);
- }
- };
-
- ws.factory = () => ws;
-
- ws.acceptConnection = () => {
- ws.readyState = 1;
- ws.onopen();
- };
- ws.close = () => {
- ws.readyState = 3;
- ws.onclose();
- };
-
- ws.reply = (msg: JsonRpcMessage) => {
- ws.onmessage({ data: JSON.stringify(msg) });
- };
- ws.replyOk = (id: string, msg) => {
- ws.reply(jsonrpc.success(id, msg || ''));
- };
- ws.replyFail = (id: string, msg: string, code: number) => {
- ws.reply(jsonrpc.error(id, new jsonrpc.JsonRpcError(msg, code)));
- };
-
- return ws;
-}
-
-function setupIpc() {
- const ws = mockWebsocket();
- return {
- ws: ws,
- ipc: new Ipc('1.2.3.4', ws.factory),
- };
-}
diff --git a/test/jsonrpc-transport.spec.js b/test/jsonrpc-transport.spec.js
new file mode 100644
index 0000000000..124bba8f00
--- /dev/null
+++ b/test/jsonrpc-transport.spec.js
@@ -0,0 +1,144 @@
+// @flow
+
+import JsonRpcTransport, {
+ TimeOutError as JsonRpcTransportTimeOutError,
+} from '../app/lib/jsonrpc-transport';
+import jsonrpc from 'jsonrpc-lite';
+import { Server, WebSocket as MockWebSocket } from 'mock-socket';
+
+describe('JSON RPC transport', () => {
+ const WEBSOCKET_URL = 'ws://localhost:8080';
+ let server: Server, transport: JsonRpcTransport;
+
+ beforeEach(() => {
+ server = new Server(WEBSOCKET_URL);
+ transport = new JsonRpcTransport((s) => new MockWebSocket(s));
+ });
+
+ afterEach(() => {
+ server.close();
+ });
+
+ it('should send as soon as the websocket connects', (done) => {
+ server.on('message', (msg) => {
+ const { payload } = jsonrpc.parse(msg);
+
+ if (payload.method === 'hello') {
+ server.send(JSON.stringify(jsonrpc.success(payload.id, 'ok')));
+ }
+ });
+
+ transport
+ .send('hello')
+ .then(() => {
+ done();
+ })
+ .catch((error) => {
+ done(error);
+ });
+
+ transport.connect(WEBSOCKET_URL);
+ });
+
+ it('should reject failed jsonrpc requests', (done) => {
+ server.on('message', (msg) => {
+ const { payload } = jsonrpc.parse(msg);
+
+ if (payload.method === 'invalid-method') {
+ server.send(
+ JSON.stringify(
+ jsonrpc.error(payload.id, new jsonrpc.JsonRpcError('Method not found', -32601)),
+ ),
+ );
+ }
+ });
+
+ transport.send('invalid-method').catch((error) => {
+ try {
+ expect(error.code).to.equal(-32601);
+ expect(error.message).to.contain('Method not found');
+ done();
+ } catch (error) {
+ done(error);
+ }
+ });
+
+ transport.connect(WEBSOCKET_URL);
+ });
+
+ it('should route reply to correct promise', () => {
+ server.on('message', (msg) => {
+ const { payload } = jsonrpc.parse(msg);
+
+ if (payload.method === 'a message') {
+ server.send(JSON.stringify(jsonrpc.success(payload.id, 'a reply')));
+ }
+ });
+
+ const decoy = transport
+ .send('a decoy', [], 100)
+ .then(() => {
+ throw new Error('Should not be called');
+ })
+ .catch((error) => {
+ expect(error).to.be.an.instanceof(JsonRpcTransportTimeOutError);
+ });
+
+ const message = transport.send('a message', [], 100).then((reply) => {
+ expect(reply).to.equal('a reply');
+ });
+
+ transport.connect(WEBSOCKET_URL);
+
+ return Promise.all([message, decoy]);
+ });
+
+ it('should timeout if no response is returned', (done) => {
+ transport
+ .send('timeout-message', {}, 1)
+ .then(() => {
+ done(new Error('Should not be called'));
+ })
+ .catch((error) => {
+ try {
+ expect(error).to.be.an.instanceof(JsonRpcTransportTimeOutError);
+ expect(error.message).to.contain('Request timed out');
+ done();
+ } catch (error) {
+ done(error);
+ }
+ });
+
+ transport.connect(WEBSOCKET_URL);
+ });
+
+ it('should route notifications', (done) => {
+ server.on('message', (msg) => {
+ const { payload } = jsonrpc.parse(msg);
+
+ if (payload.method === 'event_subscribe') {
+ server.send(JSON.stringify(jsonrpc.success(payload.id, 1)));
+ }
+ });
+
+ transport
+ .subscribe('event', (event) => {
+ try {
+ expect(event).to.equal('an event!');
+ done();
+ } catch (error) {
+ done(error);
+ }
+ })
+ .then(() => {
+ server.send(
+ JSON.stringify(jsonrpc.notification('event', { subscription: 1, result: 'an event!' })),
+ );
+ })
+ .catch((error) => {
+ done(error);
+ });
+
+ transport.connect(WEBSOCKET_URL);
+ });
+});
diff --git a/test/login.spec.js b/test/login.spec.js
deleted file mode 100644
index 68db14494b..0000000000
--- a/test/login.spec.js
+++ /dev/null
@@ -1,83 +0,0 @@
-// @flow
-
-import {
- setupBackendAndStore,
- setupBackendAndMockStore,
- checkNextTick,
- getLocation,
- failFast,
- check,
-} from './helpers/ipc-helpers';
-import { IpcChain } from './helpers/IpcChain';
-import accountActions from '../app/redux/account/actions';
-
-describe('Logging in', () => {
- it('should validate the account number and then set it in the backend', (done) => {
- const { store, mockIpc, backend } = setupBackendAndStore();
-
- const chain = new IpcChain(mockIpc);
- chain.expect('getAccountData').withInputValidation((an) => {
- expect(an).to.equal('123');
- });
-
- chain.expect('setAccount').withInputValidation((an) => {
- expect(an).to.equal('123');
- });
-
- chain.onSuccessOrFailure(done);
-
- store.dispatch(accountActions.login(backend, '123'));
- });
-
- it('should put the account data in the state', () => {
- const { store, backend, mockIpc } = setupBackendAndStore();
- mockIpc.getAccountData = () =>
- new Promise((r) =>
- r({
- expiry: '2001-01-01T00:00:00Z',
- }),
- );
-
- return backend.login('123').then(() => {
- const state = store.getState().account;
- expect(state.status).to.equal('ok');
- expect(state.accountToken).to.equal('123');
- expect(state.expiry).to.equal('2001-01-01T00:00:00Z');
- });
- });
-
- it('should indicate failure for non-existing accounts', (done) => {
- const { store, mockIpc, backend } = setupBackendAndStore();
-
- mockIpc.getAccountData = (_num) =>
- new Promise((_, reject) => {
- reject('NO SUCH ACCOUNT');
- });
-
- store.dispatch(accountActions.login(backend, '123'));
-
- checkNextTick(() => {
- const state = store.getState().account;
- expect(state.status).to.equal('failed');
- expect(state.error).to.not.be.null;
- }, done);
- });
-
- it('should redirect to /connect after 1s after successful login', (done) => {
- const { store, backend } = setupBackendAndMockStore();
-
- store.dispatch(accountActions.login(backend, '123'));
-
- setTimeout(() => {
- failFast(() => {
- expect(getLocation(store)).not.to.equal('/connect');
- }, done);
- }, 100);
-
- setTimeout(() => {
- check(() => {
- expect(getLocation(store)).to.equal('/connect');
- }, done);
- }, 1100);
- });
-});
diff --git a/test/logout.spec.js b/test/logout.spec.js
deleted file mode 100644
index 65bc3aba09..0000000000
--- a/test/logout.spec.js
+++ /dev/null
@@ -1,67 +0,0 @@
-// @flow
-
-import {
- setupBackendAndStore,
- setupBackendAndMockStore,
- getLocation,
- checkNextTick,
- failFastNextTick,
-} from './helpers/ipc-helpers';
-import { IpcChain } from './helpers/IpcChain';
-import accountActions from '../app/redux/account/actions';
-
-describe('logging out', () => {
- it('should set the account to null and then disconnect', (done) => {
- const { mockIpc, backend } = setupBackendAndStore();
-
- const chain = new IpcChain(mockIpc);
- chain.expect('setAccount').withInputValidation((num) => {
- expect(num).to.be.null;
- });
- chain.expect('disconnect');
- chain.onSuccessOrFailure(done);
-
- backend.logout();
- });
-
- it('should remove the account number from the store', (done) => {
- const { store, backend, mockIpc } = setupBackendAndStore();
- mockIpc.getAccountData = () =>
- new Promise((r) =>
- r({
- expiry: '2001-01-01T00:00:00.000Z',
- }),
- );
- const action: any = accountActions.login(backend, '123');
- store.dispatch(action);
-
- const expectedLogoutState = {
- status: 'none',
- accountToken: null,
- expiry: null,
- error: null,
- };
-
- failFastNextTick(() => {
- let state = store.getState().account;
- expect(state).not.to.include(expectedLogoutState);
-
- backend.logout();
-
- checkNextTick(() => {
- state = store.getState().account;
- expect(state).to.include(expectedLogoutState);
- }, done);
- }, done);
- });
-
- it('should redirect to / on logout', (done) => {
- const { store, backend } = setupBackendAndMockStore();
-
- backend.logout();
-
- checkNextTick(() => {
- expect(getLocation(store)).to.equal('/');
- }, done);
- });
-});
diff --git a/test/mocks/redux.js b/test/mocks/redux.js
deleted file mode 100644
index bbab7b17f4..0000000000
--- a/test/mocks/redux.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import configureMockStore from 'redux-mock-store';
-import thunk from 'redux-thunk';
-
-export const mockStore = configureMockStore([thunk]);
diff --git a/test/mocks/ipc.js b/test/mocks/rpc.js
index 590313ec13..4b37ca35aa 100644
--- a/test/mocks/ipc.js
+++ b/test/mocks/rpc.js
@@ -1,39 +1,38 @@
// @flow
-import type { IpcFacade, AccountToken, AccountData, BackendState } from '../../app/lib/ipc-facade';
+import type {
+ DaemonRpcProtocol,
+ AccountToken,
+ AccountData,
+ BackendState,
+} from '../../app/lib/daemon-rpc';
-interface MockIpc {
+interface MockRpc {
sendNewState: (BackendState) => void;
- killWebSocket: () => void;
-getAccountData: (AccountToken) => Promise<AccountData>;
- -connect: () => Promise<void>;
+ -connectTunnel: () => Promise<void>;
-getAccount: () => Promise<?AccountToken>;
-authenticate: (string) => Promise<void>;
}
-export function newMockIpc() {
+export function newMockRpc() {
const stateListeners = [];
- const connectionCloseListeners = [];
+ const openListeners = [];
+ const closeListeners = [];
- const mockIpc: IpcFacade & MockIpc = {
+ const mockIpc: DaemonRpcProtocol & MockRpc = {
setConnectionString: (_str: string) => {},
-
getAccountData: (accountToken) =>
Promise.resolve({
accountToken: accountToken,
expiry: '',
}),
-
getRelayLocations: () =>
Promise.resolve({
countries: [],
}),
-
getAccount: () => Promise.resolve('1111'),
-
setAccount: () => Promise.resolve(),
-
updateRelaySettings: () => Promise.resolve(),
-
getRelaySettings: () =>
Promise.resolve({
custom_tunnel_endpoint: {
@@ -46,17 +45,20 @@ export function newMockIpc() {
},
},
}),
-
setAllowLan: (_allowLan: boolean) => Promise.resolve(),
-
getAllowLan: () => Promise.resolve(true),
-
- connect: () => Promise.resolve(),
-
- disconnect: () => Promise.resolve(),
-
- shutdown: () => Promise.resolve(),
-
+ connect: () => {
+ for (const listener of openListeners) {
+ listener();
+ }
+ },
+ disconnect: () => {
+ for (const listener of closeListeners) {
+ listener();
+ }
+ },
+ connectTunnel: () => Promise.resolve(),
+ disconnectTunnel: () => Promise.resolve(),
getLocation: () =>
Promise.resolve({
ip: '',
@@ -66,38 +68,35 @@ export function newMockIpc() {
longitude: 0.0,
mullvad_exit_ip: false,
}),
-
getState: () =>
Promise.resolve({
state: 'unsecured',
target_state: 'unsecured',
}),
-
- registerStateListener: (listener: (BackendState) => void) => {
+ subscribeStateListener: (listener: (state: ?BackendState, error: ?Error) => void) => {
stateListeners.push(listener);
+ return Promise.resolve();
},
-
sendNewState: (state: BackendState) => {
for (const listener of stateListeners) {
listener(state);
}
},
-
- setCloseConnectionHandler: (listener: () => void) => {
- connectionCloseListeners.push(listener);
+ addOpenConnectionObserver: (listener: () => void) => {
+ openListeners.push(listener);
+ return {
+ unsubscribe: () => {},
+ };
+ },
+ addCloseConnectionObserver: (listener: (error: ?Error) => void) => {
+ closeListeners.push(listener);
+ return {
+ unsubscribe: () => {},
+ };
},
-
authenticate: (_secret: string) => Promise.resolve(),
-
getAccountHistory: () => Promise.resolve([]),
-
removeAccountFromHistory: (_accountToken) => Promise.resolve(),
-
- killWebSocket: () => {
- for (const listener of connectionCloseListeners) {
- listener();
- }
- },
};
return mockIpc;
diff --git a/yarn.lock b/yarn.lock
index e489cb06c4..e25dbfd6d7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1822,7 +1822,7 @@ commander@2:
version "2.14.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.14.1.tgz#2235123e37af8ca3c65df45b026dbd357b01b9aa"
-commander@2.15.1, commander@^2.15.1:
+commander@2.15.1, commander@^2.15.1, commander@^2.9.0:
version "2.15.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f"
@@ -1830,7 +1830,7 @@ commander@^2.11.0:
version "2.12.2"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.12.2.tgz#0f5946c427ed9ec0d91a46bb9def53e54650e555"
-commander@^2.2.0, commander@^2.9.0:
+commander@^2.2.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4"
dependencies:
@@ -4853,10 +4853,6 @@ lodash.isfinite@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/lodash.isfinite/-/lodash.isfinite-3.3.2.tgz#fb89b65a9a80281833f0b7478b3a5104f898ebb3"
-lodash.isplainobject@^4.0.6:
- version "4.0.6"
- resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
-
lodash.keys@^3.0.0:
version "3.1.2"
resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a"
@@ -5265,6 +5261,10 @@ mocha@^5.2.0:
mkdirp "0.5.1"
supports-color "5.4.0"
+mock-socket@^7.1.0:
+ version "7.1.0"
+ resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-7.1.0.tgz#482ecccafb0f0e86b8905ba2aa57c1fe73ba262d"
+
moment@^2.20.1:
version "2.20.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd"
@@ -6537,12 +6537,6 @@ redent@^1.0.0:
indent-string "^2.1.0"
strip-indent "^1.0.1"
-redux-mock-store@^1.3.0:
- version "1.4.0"
- resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.4.0.tgz#cdc87650f5759f293588fecc9cac2b057d95190d"
- dependencies:
- lodash.isplainobject "^4.0.6"
-
redux-thunk@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.2.0.tgz#e615a16e16b47a19a515766133d1e3e99b7852e5"
@@ -7911,8 +7905,8 @@ validate-npm-package-license@^3.0.1:
spdx-expression-parse "^3.0.0"
validated@^1.1.0:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/validated/-/validated-1.1.1.tgz#176daaf2537d51cee708160238fd774a32b4a20a"
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/validated/-/validated-1.2.0.tgz#44ca3791cd5b2fc24433f7d1ae3a8896dfe1aec3"
dependencies:
commander "^2.9.0"
custom-error-instance "^2.1.1"