// @flow import React from 'react'; import { bindActionCreators } from 'redux'; import { Provider } from 'react-redux'; import { ConnectedRouter, push as pushHistory } from 'react-router-redux'; import { createMemoryHistory } from 'history'; import { webFrame, ipcRenderer } from 'electron'; import makeRoutes from './routes'; import { log } from './lib/platform'; import ReconnectionBackoff from './lib/reconnection-backoff'; import { DaemonRpc } from './lib/daemon-rpc'; import { setShutdownHandler } from './shutdown-handler'; import { RemoteError as JsonRpcTransportRemoteError, TimeOutError as JsonRpcTransportTimeOutError, } from './lib/jsonrpc-transport'; import { UnknownError, NoAccountError, CommunicationError, InvalidAccountError, NoDaemonError, } from './errors'; import configureStore from './redux/store'; import accountActions from './redux/account/actions'; import connectionActions from './redux/connection/actions'; import settingsActions from './redux/settings/actions'; import type { RpcCredentials } from './lib/rpc-address-file'; import type { DaemonRpcProtocol, ConnectionObserver as DaemonConnectionObserver, } from './lib/daemon-rpc'; import type { ReduxStore } from './redux/store'; import type { AccountToken, BackendState, RelaySettingsUpdate } from './lib/daemon-rpc'; import type { ConnectionState } from './redux/connection/reducers'; import type { TrayIconType } from './tray-icon-controller'; export default class AppRenderer { _daemonRpc: DaemonRpcProtocol = new DaemonRpc(); _reconnectBackoff = new ReconnectionBackoff(); _credentials: ?RpcCredentials; _openConnectionObserver: ?DaemonConnectionObserver; _closeConnectionObserver: ?DaemonConnectionObserver; _memoryHistory = createMemoryHistory(); _reduxStore: ReduxStore; _reduxActions: *; constructor() { const store = configureStore(null, this._memoryHistory); const dispatch = store.dispatch; this._reduxStore = store; this._reduxActions = { account: bindActionCreators(accountActions, dispatch), connection: bindActionCreators(connectionActions, dispatch), settings: bindActionCreators(settingsActions, dispatch), history: bindActionCreators({ push: pushHistory }, dispatch), }; this._openConnectionObserver = this._daemonRpc.addOpenConnectionObserver(() => { this._onOpenConnection(); }); this._closeConnectionObserver = this._daemonRpc.addCloseConnectionObserver((error) => { this._onCloseConnection(error); }); this._setupReachability(); setShutdownHandler(async () => { log.info('Executing a shutdown handler'); try { await this.disconnectTunnel(); log.info('Disconnected the tunnel'); } catch (e) { log.error(`Failed to shutdown tunnel: ${e.message}`); } }); // disable pinch to zoom webFrame.setVisualZoomLevelLimits(1, 1); } dispose() { const openConnectionObserver = this._openConnectionObserver; const closeConnectionObserver = this._closeConnectionObserver; if (openConnectionObserver) { openConnectionObserver.unsubscribe(); this._openConnectionObserver = null; } if (closeConnectionObserver) { closeConnectionObserver.unsubscribe(); this._closeConnectionObserver = null; } } renderView() { return ( {makeRoutes(this._reduxStore.getState, { app: this })} ); } connect() { this._connectToDaemon(); } disconnect() { this._daemonRpc.disconnect(); } async login(accountToken: AccountToken) { const actions = this._reduxActions; actions.account.startLogin(accountToken); log.debug('Attempting to login'); try { const accountData = await this._daemonRpc.getAccountData(accountToken); await this._daemonRpc.setAccount(accountToken); actions.account.loginSuccessful(accountData.expiry); // Redirect the user after some time to allow for // the 'Login Successful' screen to be visible setTimeout(() => { actions.history.push('/connect'); log.debug('Autoconnecting...'); this.connectTunnel(); }, 1000); } catch (error) { log.error('Failed to log in,', error.message); actions.account.loginFailed(this._rpcErrorToBackendError(error)); } } async _autologin() { const actions = this._reduxActions; actions.account.startLogin(); log.debug('Attempting to log in automatically'); try { const accountToken = await this._daemonRpc.getAccount(); if (!accountToken) { throw new NoAccountError(); } log.debug(`The daemon had an account number stored: ${accountToken}`); actions.account.startLogin(accountToken); const accountData = await this._daemonRpc.getAccountData(accountToken); log.debug('The stored account number still exists:', accountData); actions.account.loginSuccessful(accountData.expiry); actions.history.push('/connect'); } catch (e) { log.warn('Unable to autologin,', e.message); actions.account.autoLoginFailed(); actions.history.push('/'); throw e; } } async logout() { const actions = this._reduxActions; 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 connectTunnel() { const actions = this._reduxActions; try { const currentState = await this._daemonRpc.getState(); if (currentState.state === 'secured') { log.debug('Refusing to connect as connection is already secured'); actions.connection.connected(); } else { actions.connection.connecting(); await this._daemonRpc.connectTunnel(); } } catch (error) { actions.connection.disconnected(); throw error; } } disconnectTunnel() { return this._daemonRpc.disconnectTunnel(); } updateRelaySettings(relaySettings: RelaySettingsUpdate) { return this._daemonRpc.updateRelaySettings(relaySettings); } async fetchRelaySettings() { const actions = this._reduxActions; const relaySettings = await this._daemonRpc.getRelaySettings(); log.debug('Got relay settings from daemon', JSON.stringify(relaySettings)); if (relaySettings.normal) { const payload = {}; const normal = relaySettings.normal; const tunnel = normal.tunnel; const location = normal.location; if (location === 'any') { payload.location = 'any'; } else { payload.location = location.only; } if (tunnel === 'any') { payload.port = 'any'; payload.protocol = 'any'; } else { const { port, protocol } = tunnel.only.openvpn; payload.port = port === 'any' ? port : port.only; payload.protocol = protocol === 'any' ? protocol : protocol.only; } actions.settings.updateRelay({ normal: payload, }); } else if (relaySettings.custom_tunnel_endpoint) { const custom_tunnel_endpoint = relaySettings.custom_tunnel_endpoint; const { host, tunnel: { openvpn: { port, protocol }, }, } = custom_tunnel_endpoint; actions.settings.updateRelay({ custom_tunnel_endpoint: { host, port, protocol, }, }); } } async updateAccountExpiry() { const actions = this._reduxActions; try { const accountToken = await this._daemonRpc.getAccount(); if (!accountToken) { throw new NoAccountError(); } 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): Promise { await this._daemonRpc.removeAccountFromHistory(accountToken); await this._fetchAccountHistory(); } async _fetchAccountHistory(): Promise { const actions = this._reduxActions; const accountHistory = await this._daemonRpc.getAccountHistory(); actions.account.updateAccountHistory(accountHistory); } async _fetchRelayLocations() { const actions = this._reduxActions; const locations = await this._daemonRpc.getRelayLocations(); log.info('Got relay locations'); const storedLocations = locations.countries.map((country) => ({ name: country.name, code: country.code, hasActiveRelays: country.cities.some((city) => city.has_active_relays), cities: country.cities.map((city) => ({ name: city.name, code: city.code, latitude: city.latitude, longitude: city.longitude, hasActiveRelays: city.has_active_relays, })), })); actions.settings.updateRelayLocations(storedLocations); } async _fetchLocation() { const actions = this._reduxActions; const location = await this._daemonRpc.getLocation(); log.info('Got location from daemon'); const locationUpdate = { ip: location.ip, country: location.country, city: location.city, latitude: location.latitude, longitude: location.longitude, mullvadExitIp: location.mullvad_exit_ip, }; 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 _connectToDaemon(): Promise { 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 _onOpenConnection() { this._reconnectBackoff.reset(); // 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 _onCloseConnection(error: ?Error) { if (error) { log.debug(`Lost connection to daemon: ${error.message}`); const recover = async () => { try { await this.connect(); } catch (error) { log.error(`Failed to reconnect: ${error.message}`); } }; this._reconnectBackoff.attempt(() => { recover(); }); } } _requestCredentials(): Promise { return new Promise((resolve, _reject) => { ipcRenderer.once('daemon-connection-ready', (_event, credentials: RpcCredentials) => { resolve(credentials); }); ipcRenderer.send('discover-daemon-connection'); }); } /** * Start reachability monitoring for online/offline detection * This is currently done via HTML5 APIs but will be replaced later * with proper mullvad-daemon integration. */ _setupReachability() { const actions = this._reduxActions; window.addEventListener('online', () => { actions.connection.online(); }); window.addEventListener('offline', () => { actions.connection.offline(); }); if (navigator.onLine) { actions.connection.online(); } else { actions.connection.offline(); } } 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 daemon {state: ${newState.state}, target_state: ${ newState.target_state }}, translated to '${connectionState}'`, ); this._updateConnectionState(connectionState); this._refreshStateOnChange(); } }); } _fetchInitialState() { return Promise.all([ this._fetchSecurityState(), this.fetchRelaySettings(), this._fetchRelayLocations(), this._fetchAllowLan(), this._fetchLocation(), this._fetchAccountHistory(), ]); } _updateTrayIcon(connectionState: ConnectionState) { const iconTypes: { [ConnectionState]: TrayIconType } = { connected: 'secured', connecting: 'securing', }; const type = iconTypes[connectionState] || 'unsecured'; ipcRenderer.send('change-tray-icon', type); } 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'; } else if (backendState.state === 'secured' && backendState.target_state === 'secured') { return 'connected'; } else if (backendState.target_state === 'unsecured') { return 'disconnected'; } throw new Error('Unsupported state/target state combination: ' + JSON.stringify(backendState)); } _updateConnectionState(connectionState: ConnectionState) { const actions = this._reduxActions; switch (connectionState) { case 'connecting': actions.connection.connecting(); break; case 'connected': actions.connection.connected(); break; case 'disconnected': actions.connection.disconnected(); break; } this._updateTrayIcon(connectionState); } _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(); } } 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._daemonRpc.authenticate(sharedSecret); log.info('Authenticated with backend'); } catch (e) { log.error(`Failed to authenticate with backend: ${e.message}`); throw e; } } }