diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2018-07-03 13:39:18 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2018-07-03 13:39:18 +0200 |
| commit | 38968509b59329f2d0743312fc8f9a4f98daa66c (patch) | |
| tree | f6e5f8b4f8dc72ae1bae0ddfe715b8094deb3750 | |
| parent | be096ee87bb1256b67c3b24b61be77d523aff9cc (diff) | |
| parent | b3a6f58d7027e5961180b52d6473f7f504638284 (diff) | |
| download | mullvadvpn-38968509b59329f2d0743312fc8f9a4f98daa66c.tar.xz mullvadvpn-38968509b59329f2d0743312fc8f9a4f98daa66c.zip | |
Merge branch 'app-overhaul'
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; @@ -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" |
