summaryrefslogtreecommitdiffhomepage
path: root/app/lib/backend.js
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2018-07-03 13:39:18 +0200
committerAndrej Mihajlov <and@mullvad.net>2018-07-03 13:39:18 +0200
commit38968509b59329f2d0743312fc8f9a4f98daa66c (patch)
treef6e5f8b4f8dc72ae1bae0ddfe715b8094deb3750 /app/lib/backend.js
parentbe096ee87bb1256b67c3b24b61be77d523aff9cc (diff)
parentb3a6f58d7027e5961180b52d6473f7f504638284 (diff)
downloadmullvadvpn-38968509b59329f2d0743312fc8f9a4f98daa66c.tar.xz
mullvadvpn-38968509b59329f2d0743312fc8f9a4f98daa66c.zip
Merge branch 'app-overhaul'
Diffstat (limited to 'app/lib/backend.js')
-rw-r--r--app/lib/backend.js603
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;
+ }
+}