You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

221 lines
7.0 KiB

import dns from 'dns'
import fs from 'fs'
import axios from 'axios'
import { promisify } from 'util'
import { basename, dirname, join, normalize } from 'path'
import { platform } from 'os'
import { app } from 'electron'
import isDev from 'electron-is-dev'
import grpc from 'grpc'
import isFQDN from 'validator/lib/isFQDN'
import isIP from 'validator/lib/isIP'
import isPort from 'validator/lib/isPort'
import get from 'lodash.get'
import { mainLog } from '../utils/log'
const fsReadFile = promisify(fs.readFile)
const dnsLookup = promisify(dns.lookup)
// ------------------------------------
// Constants
// ------------------------------------
/**
* Get a path to prepend to any nodejs calls that are getting at files in the package,
* so that it works both from source and in an asar-packaged mac app.
* See https://github.com/electron-userland/electron-builder/issues/751
*
* windows from source: "C:\myapp\node_modules\electron\dist\resources\default_app.asar"
* mac from source: "/Users/me/dev/myapp/node_modules/electron/dist/Electron.app/Contents/Resources/default_app.asar"
* mac from a package: <appRootPathsomewhere>"/my.app/Contents/Resources/app.asar"
*
* If we are run from outside of a packaged app, our working directory is the right place to be.
* And no, we can't just set our working directory to somewhere inside the asar. The OS can't handle that.
* @return {String} Path to the lnd binary.
*/
export const appRootPath = () => {
return app.getAppPath().indexOf('default_app.asar') < 0 ? normalize(`${app.getAppPath()}/..`) : ''
}
/**
* Get the OS specific lnd binary name.
* @return {String} 'lnd' on mac or linux, 'lnd.exe' on windows.
*/
export const binaryName = platform() === 'win32' ? 'lnd.exe' : 'lnd'
/**
* Get the OS specific path to the lnd binary.
* @return {String} Path to the lnd binary.
*/
export const binaryPath = () => {
return isDev
? join(dirname(require.resolve('lnd-binary/package.json')), 'vendor', binaryName)
: join(appRootPath(), 'bin', binaryName)
}
// ------------------------------------
// Helpers
// ------------------------------------
/**
* Helper function to get the current block height.
* @return {Number} The current block height.
*/
export const fetchBlockHeight = () => {
const sources = [
{
baseUrl: `https://testnet-api.smartbit.com.au/v1/blockchain/blocks?limit=1`,
path: 'blocks[0].height'
},
{
baseUrl: `https://tchain.api.btc.com/v3/block/latest`,
path: 'data.height'
},
{
baseUrl: `https://api.blockcypher.com/v1/btc/test3`,
path: 'height'
}
]
const fetchData = (baseUrl, path) => {
mainLog.info(`Fetching current block height from ${baseUrl}`)
return axios({
method: 'get',
timeout: 5000,
url: baseUrl
})
.then(response => {
const height = Number(get(response.data, path))
mainLog.info(`Fetched block height as ${height} from: ${baseUrl}`)
return height
})
.catch(err => {
mainLog.warn(`Unable to fetch block height from ${baseUrl}: ${err.message}`)
})
}
return Promise.race(sources.map(source => fetchData(source.baseUrl, source.path)))
}
/**
* 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 (!isFQDN(lndHost, { require_tld: false }) && !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 => {
let lndCert
if (certPath) {
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 metadata = new grpc.Metadata()
if (macaroonPath) {
// If it's not a filepath, then assume it is a hex encoded string.
if (macaroonPath === basename(macaroonPath)) {
metadata.add('macaroon', macaroonPath)
} else {
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
})
metadata.add('macaroon', macaroon.toString('hex'))
}
}
return grpc.credentials.createFromMetadataGenerator((params, callback) =>
callback(null, metadata)
)
}
/**
* Wait for a file to exist.
* @param {String} filepath
*/
export const waitForFile = (filepath, timeout = 1000) => {
let timeoutId
let intervalId
// Promise A rejects after the timeout has passed.
let promiseA = new Promise((resolve, reject) => {
timeoutId = setTimeout(() => {
mainLog.debug('deadline (%sms) exceeded before file (%s) was found', timeout, filepath)
clearInterval(intervalId)
clearTimeout(timeoutId)
reject(new Error(`Unable to find file: ${filepath}`))
}, timeout)
})
// Promise B resolves when the file has been found.
let promiseB = new Promise(resolve => {
let intervalId = setInterval(() => {
mainLog.debug('waiting for file: %s', filepath)
if (!fs.existsSync(filepath)) {
return
}
mainLog.debug('found file: %s', filepath)
clearInterval(intervalId)
clearTimeout(timeoutId)
resolve()
}, 200)
})
// Let's race our promises.
return Promise.race([promiseA, promiseB])
}