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 /app/lib/backend.js | |
| parent | be096ee87bb1256b67c3b24b61be77d523aff9cc (diff) | |
| parent | b3a6f58d7027e5961180b52d6473f7f504638284 (diff) | |
| download | mullvadvpn-38968509b59329f2d0743312fc8f9a4f98daa66c.tar.xz mullvadvpn-38968509b59329f2d0743312fc8f9a4f98daa66c.zip | |
Merge branch 'app-overhaul'
Diffstat (limited to 'app/lib/backend.js')
| -rw-r--r-- | app/lib/backend.js | 603 |
1 files changed, 345 insertions, 258 deletions
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; + } +} |
