Browse Source

Merge pull request #501 from mrfelton/refactor/hadle-grpc-startup-errors

feat(grpc): lnd connection error recovery
renovate/lint-staged-8.x
Ben Woosley 6 years ago
committed by GitHub
parent
commit
42f893271c
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 37
      app/components/Onboarding/ConnectionDetails.js
  2. 25
      app/components/Onboarding/ConnectionDetails.scss
  3. 10
      app/components/Onboarding/Onboarding.js
  4. 5
      app/containers/Root.js
  5. 4
      app/lnd/config/index.js
  6. 14
      app/lnd/index.js
  7. 41
      app/lnd/lib/lightning.js
  8. 4
      app/lnd/lib/neutrino.js
  9. 93
      app/lnd/lib/util.js
  10. 2
      app/lnd/lib/walletUnlocker.js
  11. 2
      app/package.json
  12. 2
      app/reducers/ipc.js
  13. 20
      app/reducers/onboarding.js
  14. 6
      app/yarn.lock
  15. 84
      app/zap.js
  16. 3
      package.json
  17. 4
      yarn.lock

37
app/components/Onboarding/ConnectionDetails.js

@ -8,45 +8,57 @@ const ConnectionDetails = ({
connectionMacaroon,
setConnectionHost,
setConnectionCert,
setConnectionMacaroon
setConnectionMacaroon,
startLndHostError,
startLndCertError,
startLndMacaroonError
}) => (
<div className={styles.container}>
<div>
<section className={styles.input}>
<label htmlFor="connectionHost">Host:</label>
<input
type="text"
id="connectionHost"
placeholder="Hostname / Port of the Lnd gRPC interface"
className={styles.host}
className={`${styles.host} ${startLndHostError && styles.error}`}
ref={input => input}
value={connectionHost}
onChange={event => setConnectionHost(event.target.value)}
/>
</div>
<div>
<p className={`${startLndHostError && styles.visible} ${styles.errorMessage}`}>
{startLndHostError}
</p>
</section>
<section className={styles.input}>
<label htmlFor="connectionCert">TLS Certificate:</label>
<input
type="text"
id="connectionCert"
placeholder="Path to the lnd tls cert"
className={styles.cert}
className={`${styles.cert} ${startLndCertError && styles.error}`}
ref={input => input}
value={connectionCert}
onChange={event => setConnectionCert(event.target.value)}
/>
</div>
<div>
<p className={`${startLndCertError && styles.visible} ${styles.errorMessage}`}>
{startLndCertError}
</p>
</section>
<section className={styles.input}>
<label htmlFor="connectionMacaroon">Macaroon:</label>
<input
type="text"
id="connectionMacaroon"
placeholder="Path to the lnd macaroon file"
className={styles.macaroon}
className={`${styles.macaroon} ${startLndMacaroonError && styles.error}`}
ref={input => input}
value={connectionMacaroon}
onChange={event => setConnectionMacaroon(event.target.value)}
/>
</div>
<p className={`${startLndMacaroonError && styles.visible} ${styles.errorMessage}`}>
{startLndMacaroonError}
</p>
</section>
</div>
)
@ -56,7 +68,10 @@ ConnectionDetails.propTypes = {
connectionMacaroon: PropTypes.string.isRequired,
setConnectionHost: PropTypes.func.isRequired,
setConnectionCert: PropTypes.func.isRequired,
setConnectionMacaroon: PropTypes.func.isRequired
setConnectionMacaroon: PropTypes.func.isRequired,
startLndHostError: PropTypes.string,
startLndCertError: PropTypes.string,
startLndMacaroonError: PropTypes.string
}
export default ConnectionDetails

25
app/components/Onboarding/ConnectionDetails.scss

@ -3,6 +3,10 @@
.container {
color: $white;
.input {
margin-bottom: 15px;
}
label {
display: block;
font-size: 12px;
@ -14,20 +18,35 @@
input {
background: transparent;
outline: none;
border:1px solid #404040;
border: 1px solid #404040;
border-radius: 4px;
padding: 10px;
color: $gold;
-webkit-text-fill-color: $white;
font-size: 18px;
width: 100%;
font-weight: 400;
margin-bottom: 20px;
width: 600px;
transition: all 0.25s;
&.error {
border: 1px solid $red;
}
}
input::-webkit-input-placeholder {
text-shadow: none;
-webkit-text-fill-color: initial;
}
.errorMessage {
margin-top: 10px;
color: $red;
height: 10px;
visibility: hidden;
font-size: 10px;
&.visible {
visibility: visible;
}
}
}

10
app/components/Onboarding/Onboarding.js

@ -72,10 +72,10 @@ const Onboarding = ({
back={() => changeStep(0.1)}
next={() =>
startLnd({
connectionType,
connectionHost,
connectionCert,
connectionMacaroon
type: connectionType,
host: connectionHost,
cert: connectionCert,
macaroon: connectionMacaroon
})
}
>
@ -100,7 +100,7 @@ const Onboarding = ({
title="Autopilot"
description="Autopilot is an automatic network manager. Instead of manually adding people to build your network to make payments, enable autopilot to automatically connect you to the Lightning Network using 60% of your balance." // eslint-disable-line max-len
back={() => changeStep(1)}
next={() => startLnd({ connectionType, alias, autopilot })}
next={() => startLnd({ type: connectionType, alias, autopilot })}
>
<Autopilot {...autopilotProps} />
</FormContainer>

5
app/containers/Root.js

@ -99,7 +99,10 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
connectionMacaroon: stateProps.onboarding.connectionMacaroon,
setConnectionHost: dispatchProps.setConnectionHost,
setConnectionCert: dispatchProps.setConnectionCert,
setConnectionMacaroon: dispatchProps.setConnectionMacaroon
setConnectionMacaroon: dispatchProps.setConnectionMacaroon,
startLndHostError: stateProps.onboarding.startLndHostError,
startLndCertError: stateProps.onboarding.startLndCertError,
startLndMacaroonError: stateProps.onboarding.startLndMacaroonError
}
const aliasProps = {

4
app/lnd/config/index.js

@ -61,8 +61,8 @@ export default {
lnd: () => ({
lndPath,
configPath: join(appRootPath, 'resources', 'lnd.conf'),
lightningRpc: join(appRootPath, 'resources', 'rpc.proto'),
lightningHost: store.get('host') || 'localhost:10009',
rpcProtoPath: join(appRootPath, 'resources', 'rpc.proto'),
host: store.get('host') || 'localhost:10009',
cert: store.get('cert') || join(userInfo().homedir, loc),
macaroon: store.get('macaroon') || join(userInfo().homedir, macaroonPath)
})

14
app/lnd/index.js

@ -1,28 +1,28 @@
import config from './config'
import lightning from './lib/lightning'
import walletUnlocker from './lib/walletUnlocker'
import isLndRunning from './lib/util'
import { isLndRunning } from './lib/util'
import subscribe from './subscribe'
import methods from './methods'
import walletUnlockerMethods from './walletUnlockerMethods'
// use mainLog because lndLog is reserved for the lnd binary itself
import { mainLog } from '../utils/log'
const initLnd = () => {
const lndConfig = config.lnd()
const lnd = lightning(lndConfig.lightningRpc, lndConfig.lightningHost)
const initLnd = async () => {
const lnd = await lightning()
const lndSubscribe = mainWindow => subscribe(mainWindow, lnd, mainLog)
const lndMethods = (event, msg, data) => methods(lnd, mainLog, event, msg, data)
return {
return Promise.resolve({
lndSubscribe,
lndMethods
}
})
}
const initWalletUnlocker = () => {
const lndConfig = config.lnd()
const walletUnlockerObj = walletUnlocker(lndConfig.lightningRpc, lndConfig.lightningHost)
const walletUnlockerObj = walletUnlocker(lndConfig.rpcProtoPath, lndConfig.host)
const walletUnlockerMethodsCallback = (event, msg, data) =>
walletUnlockerMethods(walletUnlockerObj, mainLog, event, msg, data)

41
app/lnd/lib/lightning.js

@ -1,6 +1,6 @@
import fs from 'fs'
import grpc from 'grpc'
import config from '../config'
import { getDeadline, validateHost, createSslCreds, createMacaroonCreds } from './util'
// Default is ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384
// https://github.com/grpc/grpc/blob/master/doc/environment_variables.md
@ -19,22 +19,39 @@ process.env.GRPC_SSL_CIPHER_SUITES =
'ECDHE-ECDSA-CHACHA20-POLY1305'
].join(':')
const lightning = (rpcpath, host) => {
/**
* Creates an LND grpc client lightning service.
* @returns {rpc.lnrpc.Lightning}
*/
const lightning = async () => {
const lndConfig = config.lnd()
const lndCert = fs.readFileSync(lndConfig.cert)
const sslCreds = grpc.credentials.createSsl(lndCert)
const rpc = grpc.load(lndConfig.lightningRpc)
const { host, rpcProtoPath, cert, macaroon } = lndConfig
const metadata = new grpc.Metadata()
const macaroonHex = fs.readFileSync(lndConfig.macaroon).toString('hex')
metadata.add('macaroon', macaroonHex)
// Verify that the host is valid before creating a gRPC client that is connected to it.
return await validateHost(host).then(async () => {
// Load the gRPC proto file.
const rpc = grpc.load(rpcProtoPath)
const macaroonCreds = grpc.credentials.createFromMetadataGenerator((params, callback) =>
callback(null, metadata)
)
// Create ssl and macaroon credentials to use with the gRPC client.
const [sslCreds, macaroonCreds] = await Promise.all([
createSslCreds(cert),
createMacaroonCreds(macaroon)
])
const credentials = grpc.credentials.combineChannelCredentials(sslCreds, macaroonCreds)
return new rpc.lnrpc.Lightning(host, credentials)
// Create a new gRPC client instance.
const lnd = new rpc.lnrpc.Lightning(host, credentials)
// Call the getInfo method to ensure that we can make successful calls to the gRPC interface.
return new Promise((resolve, reject) => {
lnd.getInfo({}, { deadline: getDeadline(2) }, err => {
if (err) {
return reject(err)
}
return resolve(lnd)
})
})
})
}
export default lightning

4
app/lnd/lib/neutrino.js

@ -20,8 +20,8 @@ class Neutrino extends EventEmitter {
const lndConfig = config.lnd()
mainLog.info('Starting lnd in neutrino mode')
mainLog.debug(' > lndPath', lndConfig.lndPath)
mainLog.debug(' > lightningRpc:', lndConfig.lightningRpc)
mainLog.debug(' > lightningHost:', lndConfig.lightningHost)
mainLog.debug(' > rpcProtoPath:', lndConfig.rpcProtoPath)
mainLog.debug(' > host:', lndConfig.host)
mainLog.debug(' > cert:', lndConfig.cert)
mainLog.debug(' > macaroon:', lndConfig.macaroon)

93
app/lnd/lib/util.js

@ -1,11 +1,100 @@
import dns from 'dns'
import fs from 'fs'
import { promisify } from 'util'
import { lookup } from 'ps-node'
import grpc from 'grpc'
import isIP from 'validator/lib/isIP'
import isPort from 'validator/lib/isPort'
import { mainLog } from '../../utils/log'
const fsReadFile = promisify(fs.readFile)
const dnsLookup = promisify(dns.lookup)
/**
* Helper function to return an absolute deadline given a relative timeout in seconds.
* @param {number} timeoutSecs The number of seconds to wait before timing out
* @return {Date} A date timeoutSecs in the future
*/
export const getDeadline = timeoutSecs => {
var deadline = new Date()
deadline.setSeconds(deadline.getSeconds() + timeoutSecs)
return deadline.getTime()
}
/**
* Helper function to check a hostname in the format hostname:port is valid for passing to node-grpc.
* @param {string} host A hostname + optional port in the format [hostname]:[port?]
* @returns {Promise<Boolean>}
*/
export const validateHost = async host => {
var splits = host.split(':')
const lndHost = splits[0]
const lndPort = splits[1]
// If the hostname starts with a number, ensure that it is a valid IP address.
if (lndHost.match(/^\d/) && !isIP(lndHost)) {
const error = new Error(`${lndHost} is not a valid IP address or hostname`)
error.code = 'LND_GRPC_HOST_ERROR'
return Promise.reject(error)
}
// If the host includes a port, ensure that it is a valid.
if (lndPort && !isPort(lndPort)) {
const error = new Error(`${lndPort} is not a valid port`)
error.code = 'LND_GRPC_HOST_ERROR'
return Promise.reject(error)
}
// Do a DNS lookup to ensure that the host is reachable.
return dnsLookup(lndHost)
.then(() => true)
.catch(e => {
const error = new Error(`${lndHost} is not accessible: ${e.message}`)
error.code = 'LND_GRPC_HOST_ERROR'
return Promise.reject(error)
})
}
/**
* Validates and creates the ssl channel credentials from the specified file path
* @param {String} certPath
* @returns {grpc.ChanelCredentials}
*/
export const createSslCreds = async certPath => {
const lndCert = await fsReadFile(certPath).catch(e => {
const error = new Error(`SSL cert path could not be accessed: ${e.message}`)
error.code = 'LND_GRPC_CERT_ERROR'
throw error
})
return grpc.credentials.createSsl(lndCert)
}
/**
* Validates and creates the macaroon authorization credentials from the specified file path
* @param {String} macaroonPath
* @returns {grpc.CallCredentials}
*/
export const createMacaroonCreds = async macaroonPath => {
const macaroon = await fsReadFile(macaroonPath).catch(e => {
const error = new Error(`Macaroon path could not be accessed: ${e.message}`)
error.code = 'LND_GRPC_MACAROON_ERROR'
throw error
})
const metadata = new grpc.Metadata()
metadata.add('macaroon', macaroon.toString('hex'))
return grpc.credentials.createFromMetadataGenerator((params, callback) =>
callback(null, metadata)
)
}
/**
* Check to see if an LND process is running.
* @return {Promise} Boolean indicating wether an existing lnd process was found on the host machine.
*/
const isLndRunning = () => {
export const isLndRunning = () => {
return new Promise((resolve, reject) => {
mainLog.info('Looking for existing lnd process')
lookup({ command: 'lnd' }, (err, results) => {
@ -24,5 +113,3 @@ const isLndRunning = () => {
})
})
}
export default isLndRunning

2
app/lnd/lib/walletUnlocker.js

@ -23,7 +23,7 @@ const walletUnlocker = (rpcpath, host) => {
const lndConfig = config.lnd()
const lndCert = fs.readFileSync(lndConfig.cert)
const credentials = grpc.credentials.createSsl(lndCert)
const rpc = grpc.load(lndConfig.lightningRpc)
const rpc = grpc.load(lndConfig.rpcProtoPath)
return new rpc.lnrpc.WalletUnlocker(host, credentials)
}

2
app/package.json

@ -14,7 +14,7 @@
},
"license": "MIT",
"dependencies": {
"grpc": "^1.12.3",
"grpc": "^1.12.4",
"ps-node": "^0.1.6",
"react-icons": "^2.2.5"
}

2
app/reducers/ipc.js

@ -40,6 +40,7 @@ import { receiveDescribeNetwork, receiveQueryRoutes, receiveInvoiceAndQueryRoute
import {
startOnboarding,
startLndError,
walletUnlockerStarted,
receiveSeed,
receiveSeedError,
@ -107,6 +108,7 @@ const ipc = createIpc({
receiveInvoiceAndQueryRoutes,
startOnboarding,
startLndError,
walletUnlockerStarted,
receiveSeed,
receiveSeedError,

20
app/reducers/onboarding.js

@ -36,6 +36,7 @@ export const ONBOARDING_FINISHED = 'ONBOARDING_FINISHED'
export const STARTING_LND = 'STARTING_LND'
export const LND_STARTED = 'LND_STARTED'
export const SET_START_LND_ERROR = 'SET_START_LND_ERROR'
export const CREATING_NEW_WALLET = 'CREATING_NEW_WALLET'
@ -45,6 +46,7 @@ export const SET_UNLOCK_WALLET_ERROR = 'SET_UNLOCK_WALLET_ERROR'
export const SET_SIGNUP_CREATE = 'SET_SIGNUP_CREATE'
export const SET_SIGNUP_IMPORT = 'SET_SIGNUP_IMPORT'
// ------------------------------------
// Actions
// ------------------------------------
@ -204,6 +206,11 @@ export const startOnboarding = () => dispatch => {
dispatch({ type: ONBOARDING_STARTED })
}
// Listener for errors connecting to LND gRPC
export const startLndError = (event, errors) => dispatch => {
dispatch({ type: SET_START_LND_ERROR, errors })
}
// Listener from after the LND walletUnlocker has started
export const walletUnlockerStarted = () => dispatch => {
dispatch({ type: LND_STARTED })
@ -293,6 +300,13 @@ const ACTION_HANDLERS = {
[STARTING_LND]: state => ({ ...state, startingLnd: true }),
[LND_STARTED]: state => ({ ...state, startingLnd: false }),
[SET_START_LND_ERROR]: (state, { errors }) => ({
...state,
startingLnd: false,
startLndHostError: errors.host,
startLndCertError: errors.cert,
startLndMacaroonError: errors.macaroon
}),
[CREATING_NEW_WALLET]: state => ({ ...state, creatingNewWallet: true }),
@ -366,7 +380,7 @@ export { onboardingSelectors }
// Reducer
// ------------------------------------
const initialState = {
onboarded: true,
onboarded: false,
step: 0.1,
connectionType: store.get('type', ''),
connectionHost: store.get('host', ''),
@ -374,7 +388,11 @@ const initialState = {
connectionMacaroon: store.get('macaroon', ''),
alias: store.get('alias', ''),
password: '',
startingLnd: false,
startLndHostError: '',
startLndCertError: '',
startLndMacaroonError: '',
fetchingSeed: false,
hasSeed: false,

6
app/yarn.lock

@ -171,9 +171,9 @@ glob@^7.0.5:
once "^1.3.0"
path-is-absolute "^1.0.0"
grpc@^1.12.3:
version "1.12.3"
resolved "https://registry.yarnpkg.com/grpc/-/grpc-1.12.3.tgz#b38bf05f26477d42f8285794c0b1f8b8c0b6dec3"
grpc@^1.12.4:
version "1.12.4"
resolved "https://registry.yarnpkg.com/grpc/-/grpc-1.12.4.tgz#85a3bc26dbf61fb8555d182aec42c1ab6b303ecd"
dependencies:
lodash "^4.17.5"
nan "^2.0.0"

84
app/zap.js

@ -54,10 +54,10 @@ class ZapController {
Promise.resolve(this.mode)
.then(mode => {
const timeUntilWeKnowTheRunMode = mainLog.timeEnd('Time until we know the run mode')
return setTimeout(() => {
return setTimeout(async () => {
if (mode === 'external') {
// If lnd is already running, create and subscribe to the Lightning grpc object.
this.startGrpc()
await this.startGrpc()
this.sendMessage('successfullyCreatedWallet')
} else {
// Otherwise, start the onboarding process.
@ -90,10 +90,9 @@ class ZapController {
/**
* Create and subscribe to the Lightning grpc object.
*/
startGrpc() {
async startGrpc() {
mainLog.info('Starting gRPC...')
try {
const { lndSubscribe, lndMethods } = lnd.initLnd()
const { lndSubscribe, lndMethods } = await lnd.initLnd()
// Subscribe to bi-directional streams
lndSubscribe(this.mainWindow)
@ -104,13 +103,6 @@ class ZapController {
})
this.sendMessage('grpcConnected')
} catch (error) {
dialog.showMessageBox({
type: 'error',
message: `Unable to connect to lnd. Please check your lnd node and try again: ${error}`
})
app.quit()
}
}
/**
@ -163,9 +155,9 @@ class ZapController {
this.startWalletUnlocker()
})
this.neutrino.on('wallet-opened', () => {
this.neutrino.on('wallet-opened', async () => {
mainLog.info('Wallet opened')
this.startGrpc()
await this.startGrpc()
this.sendMessage('lndSyncing')
})
@ -185,30 +177,56 @@ class ZapController {
* Add IPC event listeners...
*/
_registerIpcListeners() {
ipcMain.on('startLnd', (event, options = {}) => {
ipcMain.on('startLnd', async (event, options = {}) => {
// Trim any user supplied strings.
const cleanOptions = Object.keys(options).reduce((previous, current) => {
previous[current] =
typeof options[current] === 'string' ? options[current].trim() : options[current]
return previous
}, {})
// Save the options.
const store = new Store({ name: 'connection' })
store.store = {
type: options.connectionType,
host: options.connectionHost,
cert: options.connectionCert,
macaroon: options.connectionMacaroon,
alias: options.alias,
autopilot: options.autopilot
}
store.store = cleanOptions
mainLog.info('Saved lnd config to:', store.path)
if (options.connectionType === 'local') {
// If the requested connection type is a local one then start up a new lnd instance.
if (cleanOptions.type === 'local') {
mainLog.info('Starting new lnd instance')
mainLog.debug(' > alias:', options.alias)
mainLog.debug(' > autopilot:', options.autopilot)
this.startLnd(options.alias, options.autopilot)
} else {
mainLog.debug(' > alias:', cleanOptions.alias)
mainLog.debug(' > autopilot:', cleanOptions.autopilot)
this.startLnd(cleanOptions.alias, cleanOptions.autopilot)
}
// Otherwise attempt to connect to an lnd instance using user supplied connection details.
else {
mainLog.info('Connecting to custom lnd instance')
mainLog.debug(' > connectionHost:', options.connectionHost)
mainLog.debug(' > connectionCert:', options.connectionCert)
mainLog.debug(' > connectionMacaroon:', options.connectionMacaroon)
this.startGrpc()
this.sendMessage('successfullyCreatedWallet')
mainLog.debug(' > host:', cleanOptions.host)
mainLog.debug(' > cert:', cleanOptions.cert)
mainLog.debug(' > macaroon:', cleanOptions.macaroon)
await this.startGrpc()
.then(() => this.sendMessage('successfullyCreatedWallet'))
.catch(e => {
const errors = {}
// There was a problem connectig to the host.
if (e.code === 'LND_GRPC_HOST_ERROR') {
errors.host = e.message
}
// There was a problem accessing loading the ssl cert.
if (e.code === 'LND_GRPC_CERT_ERROR') {
errors.cert = e.message
}
// There was a problem accessing loading the macaroon file.
else if (e.code === 'LND_GRPC_MACAROON_ERROR') {
errors.macaroon = e.message
}
// Other error codes such as UNAVAILABLE most likely indicate that there is a problem with the host.
else {
errors.host = `Unable to connect to host: ${e.details || e.message}`
}
// Notify the app of errors.
return this.sendMessage('startLndError', errors)
})
}
})
}

3
package.json

@ -248,7 +248,8 @@
"reselect": "^3.0.1",
"satoshi-bitcoin": "^1.0.4",
"source-map-support": "^0.5.6",
"split2": "^2.2.0"
"split2": "^2.2.0",
"validator": "^10.4.0"
},
"main": "webpack.config.base.js",
"directories": {

4
yarn.lock

@ -11720,6 +11720,10 @@ validate-npm-package-license@^3.0.1:
spdx-correct "^3.0.0"
spdx-expression-parse "^3.0.0"
validator@^10.4.0:
version "10.4.0"
resolved "https://registry.yarnpkg.com/validator/-/validator-10.4.0.tgz#ee99a44afb3bb5ed350a159f056ca72a204cfc3c"
value-equal@^0.2.0:
version "0.2.1"
resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.2.1.tgz#c220a304361fce6994dbbedaa3c7e1a1b895871d"

Loading…
Cancel
Save