chore: add lnd dir option to config (#92) (#93)

* chore: add lnd dir option to config (#92)

* Add lndDir and network options in AccountType

* Add BITCOIN_NETWORK environment variable

We use this as a fallback value if no network values are set for the
individual accounts.

* Update documentation

We update the documentation to cover the new LND directory and Bitcoin
network configuration parameters.

* chore: 🔧 cleanup

* docs: 📚️ cleanup

Co-authored-by: Torkel Rogstad <torkel@rogstad.io>
This commit is contained in:
Anthony Potdevin 2020-07-16 22:50:21 +02:00 committed by GitHub
parent 27b5e832b4
commit 56e6b964c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 421 additions and 107 deletions

2
.env
View File

@ -1,6 +1,6 @@
# -----------
# IMPORTANT
# Create a new file called .env.local and copy
# Create a new file called .env.local and copy
# the environment variables that you want to
# change to this file. This way they won't be
# overwritten when you update thunderhub.

View File

@ -131,25 +131,25 @@ You can define some environment variables that ThunderHub can start with. To do
# -----------
# Server Configs
# -----------
LOG_LEVEL = 'error' | 'warn' | 'info' | 'http' | 'verbose' | 'debug' | 'silly' //Default: 'info'
HODL_KEY = '[Key provided by HodlHodl]' //Default: ''
BASE_PATH = '[Base path where you want to have thunderhub running i.e. '/btcpay']' //Default: ''
LOG_LEVEL = 'error' | 'warn' | 'info' | 'http' | 'verbose' | 'debug' | 'silly' # Default: 'info'
HODL_KEY = '[Key provided by HodlHodl]' # Default: ''
BASE_PATH = '[Base path where you want to have thunderhub running i.e. '/btcpay']' # Default: ''
# -----------
# Interface Configs
# -----------
THEME = 'dark' | 'light' // Default: 'dark'
CURRENCY = 'sat' | 'btc' | 'fiat' // Default: 'sat'
THEME = 'dark' | 'light' # Default: 'dark'
CURRENCY = 'sat' | 'btc' | 'fiat' # Default: 'sat'
# -----------
# Privacy Configs
# -----------
FETCH_PRICES = true | false // Default: true
FETCH_FEES = true | false // Default: true
HODL_HODL = true | false // Default: true
DISABLE_LINKS = true | false // Default: false
NO_CLIENT_ACCOUNTS = true | false // Default: false
NO_VERSION_CHECK = true | false // Default: false
FETCH_PRICES = true | false # Default: true
FETCH_FEES = true | false # Default: true
HODL_HODL = true | false # Default: true
DISABLE_LINKS = true | false # Default: false
NO_CLIENT_ACCOUNTS = true | false # Default: false
NO_VERSION_CHECK = true | false # Default: false
```
### SSO Account
@ -160,10 +160,10 @@ You can define an account to work with SSO cookie authentication by adding the f
# -----------
# SSO Account Configs
# -----------
COOKIE_PATH = '/path/to/cookie/file/.cookie'; // i.e. '/data/.cookie'
SSO_SERVER_URL = 'url and port to node'; // i.e. '127.0.0.1:10009'
SSO_CERT_PATH = '/path/to/tls/certificate'; // i.e. '\lnd\alice\tls.cert'
SSO_MACAROON_PATH = '/path/to/macaroon/folder'; //i.e. '\lnd\alice\data\chain\bitcoin\regtest\'
COOKIE_PATH = '/path/to/cookie/file/.cookie'; # i.e. '/data/.cookie'
SSO_SERVER_URL = 'url and port to node'; # i.e. '127.0.0.1:10009'
SSO_CERT_PATH = '/path/to/tls/certificate'; # i.e. '\lnd\alice\tls.cert'
SSO_MACAROON_PATH = '/path/to/macaroon/folder'; # i.e. '\lnd\alice\data\chain\bitcoin\regtest\'
```
To login to this account you must add the cookie file content to the end of your ThunderHub url. For example:
@ -182,7 +182,7 @@ You can add accounts on the server by adding this parameter to the `.env` file:
# -----------
# Account Configs
# -----------
ACCOUNT_CONFIG_PATH = '/path/to/config/file.yaml'; // i.e. '/data/thubConfig.yaml'
ACCOUNT_CONFIG_PATH = '/path/to/config/file.yaml'; # i.e. '/data/thubConfig.yaml'
```
You must also add a YAML file at that location with the following format:
@ -209,6 +209,33 @@ accounts:
Notice you can specify either `macaroonPath` and `certificatePath` or `macaroon` and `certificate`.
#### Account with LND directory
You can also specify the main LND directory and ThunderHub will look for the certificate and the macaroon in the default folders (based on the network).
Default folders (assuming LND is at path `/lnd`):
- Certificate: `/lnd/tls.cert`
- Macaroon: `/lnd/data/chain/bitcoin/[mainnet | testnet | regtest]/admin.macaroon`
The YAML file for this example would be:
```yaml
masterPassword: 'password' # Default password unless defined in account
defaultNetwork: 'testnet' # Default network unless defined in account
accounts:
- name: 'Account1'
serverUrl: 'url:port'
# network: Leave without network and it will use the default network
lndDir: '/path/to/lnd'
- name: 'Account2'
serverUrl: 'url:port'
network: 'mainnet'
lndDir: '/path/to/lnd'
```
If you don't specify `defaultNetwork` then `mainnet` is used as the default.
#### Security
On the first start of the server, the `masterPassword` and all account `password` fields will be **hashed** and the file will be overwritten with these new values to avoid having cleartext passwords on the server.

View File

@ -0,0 +1,209 @@
import { existsSync, readFileSync } from 'fs';
import path from 'path';
import { getParsedAccount } from '../fileHelpers';
const mockedExistsSync: jest.Mock = existsSync as any;
const mockedReadFileSync: jest.Mock = readFileSync as any;
jest.mock('fs');
describe('getParsedAccount', () => {
const masterPassword = 'master password';
it('returns null without an account name', () => {
const raw = {
lndDir: 'LND DIR',
name: '',
certificate: 'RAW CERT',
serverUrl: 'server.url',
macaroon: 'RAW MACAROON',
};
const account = getParsedAccount(raw, 0, masterPassword, 'mainnet');
expect(account).toBeNull();
});
it('returns null on invalid network', () => {
const raw = {
lndDir: 'LND DIR',
name: 'name',
certificate: 'RAW CERT',
serverUrl: 'server.url',
macaroon: 'RAW MACAROON',
network: 'foo' as any,
};
const account = getParsedAccount(raw, 0, masterPassword, 'mainnet');
expect(account).toBeNull();
});
it('returns null without an account server url', () => {
const raw = {
name: 'NAME',
certificate: 'RAW CERT',
serverUrl: '',
macaroon: 'RAW MACAROON',
};
const account = getParsedAccount(raw, 0, masterPassword, 'mainnet');
expect(account).toBeNull();
});
it('defaults to given bitcoin network', () => {
const raw = {
name: 'NAME',
serverUrl: 'server.url',
lndDir: 'lnd dir',
};
mockedExistsSync.mockReturnValue(true);
mockedReadFileSync.mockImplementation(file => {
if ((file as string).includes('tls.cert')) {
return 'cert';
}
if ((file as string).includes(path.join('regtest', 'admin.macaroon'))) {
return 'macaroon';
}
return 'something else ';
});
const account = getParsedAccount(raw, 0, masterPassword, 'regtest');
expect(account.macaroon).toContain('macaroon');
});
it('picks up other networks', () => {
const raw = {
name: 'NAME',
serverUrl: 'server.url',
lndDir: 'lnd dir',
network: 'testnet',
} as const;
mockedExistsSync.mockReturnValue(true);
mockedReadFileSync.mockImplementation(file => {
if ((file as string).includes('tls.cert')) {
return 'cert';
}
if ((file as string).includes(path.join('testnet', 'admin.macaroon'))) {
return 'macaroon';
}
return 'something else ';
});
const account = getParsedAccount(raw, 0, masterPassword, 'mainnet');
expect(account.macaroon).toContain('macaroon');
});
describe('macaroon handling', () => {
it('defaults to raw macaroon', () => {
const raw = {
lndDir: 'LND DIR',
name: 'NAME',
certificate: 'RAW CERT',
serverUrl: 'server.url',
macaroon: 'RAW MACAROON',
macaroonPath: 'MACAROON PATH',
};
const account = getParsedAccount(raw, 0, masterPassword, 'mainnet');
expect(account.macaroon).toBe('RAW MACAROON');
});
it('falls back to macaroon path after that', () => {
const raw = {
lndDir: 'LND DIR',
name: 'NAME',
certificate: 'RAW CERT',
certificatePath: 'CERT PATH',
serverUrl: 'server.url',
macaroon: '',
macaroonPath: 'MACAROON PATH',
};
mockedExistsSync.mockReturnValueOnce(true);
mockedReadFileSync.mockImplementationOnce(file => {
if ((file as string).includes(raw.macaroonPath)) {
return 'yay';
} else {
return 'nay';
}
});
const account = getParsedAccount(raw, 0, masterPassword, 'mainnet');
expect(account.macaroon).toBe('yay');
});
it('falls back to lnd dir finally', () => {
const raw = {
lndDir: 'LND DIR',
name: 'NAME',
certificate: 'RAW CERT',
certificatePath: 'CERT PATH',
serverUrl: 'server.url',
macaroon: '',
macaroonPath: '',
};
mockedExistsSync.mockReturnValueOnce(true);
mockedReadFileSync.mockImplementationOnce(file => {
if ((file as string).includes(raw.lndDir)) {
return 'yay';
} else {
return 'nay';
}
});
const account = getParsedAccount(raw, 0, masterPassword, 'mainnet');
expect(account.macaroon).toBe('yay');
});
});
describe('certificate handling', () => {
it('defaults to raw certificate', () => {
const raw = {
lndDir: 'LND DIR',
name: 'NAME',
certificate: 'RAW CERT',
certificatePath: 'CERT PATH',
serverUrl: 'server.url',
macaroon: 'RAW MACAROON',
};
const account = getParsedAccount(raw, 0, masterPassword, 'mainnet');
expect(account.cert).toBe('RAW CERT');
});
it('falls back to certificate path after that', () => {
const raw = {
lndDir: 'LND DIR',
name: 'NAME',
certificatePath: 'CERT PATH',
serverUrl: 'server.url',
macaroon: 'RAW MACAROON',
};
mockedExistsSync.mockReturnValueOnce(true);
mockedReadFileSync.mockImplementationOnce(file => {
if ((file as string).includes(raw.certificatePath)) {
return 'yay';
} else {
return 'nay';
}
});
const account = getParsedAccount(raw, 0, masterPassword, 'mainnet');
expect(account.cert).toBe('yay');
});
it('falls back to lnd dir finally', () => {
const raw = {
lndDir: 'LND DIR',
name: 'NAME',
serverUrl: 'server.url',
macaroon: 'RAW MACAROON',
};
mockedExistsSync.mockReturnValueOnce(true);
mockedReadFileSync.mockImplementationOnce(file => {
if ((file as string).includes(raw.lndDir)) {
return 'yay';
} else {
return 'nay';
}
});
const account = getParsedAccount(raw, 0, masterPassword, 'mainnet');
expect(account.cert).toBe('yay');
});
});
});

View File

@ -8,6 +8,36 @@ import { getUUID } from 'src/utils/auth';
import bcrypt from 'bcryptjs';
type EncodingType = 'hex' | 'utf-8';
type BitcoinNetwork = 'mainnet' | 'regtest' | 'testnet';
type AccountType = {
name?: string;
serverUrl?: string;
lndDir?: string;
network?: BitcoinNetwork;
macaroonPath?: string;
certificatePath?: string;
password?: string;
macaroon?: string;
certificate?: string;
};
type ParsedAccount = {
name: string;
id: string;
host: string;
macaroon: string;
cert: string;
password: string;
};
type AccountConfigType = {
hashed: boolean | null;
masterPassword: string | null;
defaultNetwork: string | null;
accounts: AccountType[];
};
const isValidNetwork = (network: string): network is BitcoinNetwork =>
network === 'mainnet' || network === 'regtest' || network === 'testnet';
export const PRE_PASS_STRING = 'thunderhub-';
@ -35,22 +65,6 @@ export const readFile = (
}
};
type AccountType = {
name: string;
serverUrl: string;
macaroonPath: string;
certificatePath: string;
password: string | null;
macaroon: string;
certificate: string;
};
type AccountConfigType = {
hashed: boolean | null;
masterPassword: string | null;
accounts: AccountType[];
};
export const parseYaml = (filePath: string): AccountConfigType | null => {
if (filePath === '') {
return null;
@ -73,10 +87,7 @@ export const parseYaml = (filePath: string): AccountConfigType | null => {
}
};
export const saveHashedYaml = (
config: AccountConfigType,
filePath: string
): void => {
const saveHashedYaml = (config: AccountConfigType, filePath: string): void => {
if (filePath === '' || !config) return;
logger.info('Saving new yaml file with hashed passwords');
@ -92,7 +103,7 @@ export const saveHashedYaml = (
}
};
export const hashPasswords = (
const hashPasswords = (
isHashed: boolean,
config: AccountConfigType,
filePath: string
@ -144,6 +155,54 @@ export const hashPasswords = (
return cloned;
};
const getCertificate = ({
certificate,
certificatePath,
lndDir,
}: AccountType): string | null => {
if (certificate) {
return certificate;
}
if (certificatePath) {
return readFile(certificatePath);
}
if (lndDir) {
return readFile(path.join(lndDir, 'tls.cert'));
}
return null;
};
const getMacaroon = (
{ macaroon, macaroonPath, network, lndDir }: AccountType,
defaultNetwork: BitcoinNetwork
): string => {
if (macaroon) {
return macaroon;
}
if (macaroonPath) {
return readFile(macaroonPath);
}
if (!lndDir) {
return null;
}
return readFile(
path.join(
lndDir,
'data',
'chain',
'bitcoin',
network || defaultNetwork,
'admin.macaroon'
)
);
};
export const getAccounts = (filePath: string) => {
if (filePath === '') {
logger.verbose('No account config file path provided');
@ -151,94 +210,113 @@ export const getAccounts = (filePath: string) => {
}
const accountConfig = parseYaml(filePath);
if (!accountConfig) {
logger.info(`No account config file found at path ${filePath}`);
return null;
}
return getAccountsFromYaml(accountConfig, filePath);
};
const { hashed, accounts: preAccounts } = accountConfig;
export const getParsedAccount = (
account: AccountType,
index: number,
masterPassword: string,
defaultNetwork: BitcoinNetwork
): ParsedAccount | null => {
const {
name,
serverUrl,
network,
lndDir,
macaroonPath,
macaroon: macaroonValue,
password,
} = account;
const missingFields: string[] = [];
if (!name) missingFields.push('name');
if (!serverUrl) missingFields.push('server url');
if (!lndDir && !macaroonPath && !macaroonValue) {
missingFields.push('macaroon');
}
if (missingFields.length > 0) {
const text = missingFields.join(', ');
logger.error(`Account in index ${index} is missing the fields ${text}`);
return null;
}
if (network && !isValidNetwork(network)) {
logger.error(`Account ${name} has invalid network: ${network}`);
return null;
}
if (!password && !masterPassword) {
logger.error(
`You must set a password for account ${name} or set a master password`
);
return null;
}
const cert = getCertificate(account);
if (!cert) {
logger.warn(
`No certificate for account ${name}. Make sure you don't need it to connect.`
);
}
const macaroon = getMacaroon(account, defaultNetwork);
if (!macaroon) {
logger.error(
`Account ${name} has neither lnd directory, macaroon nor macaroon path specified.`
);
return null;
}
const id = getUUID(`${name}${serverUrl}${macaroon}${cert}`);
return {
name,
id,
host: serverUrl,
macaroon,
cert,
password: password || masterPassword,
};
};
export const getAccountsFromYaml = (
config: AccountConfigType,
filePath: string
) => {
const { hashed, accounts: preAccounts } = config;
if (!preAccounts || preAccounts.length <= 0) {
logger.warn(`Account config found at path ${filePath} but had no accounts`);
return null;
}
const { masterPassword, accounts } = hashPasswords(
const { defaultNetwork, masterPassword, accounts } = hashPasswords(
hashed,
accountConfig,
config,
filePath
);
const readAccounts = [];
const network: BitcoinNetwork = isValidNetwork(defaultNetwork)
? defaultNetwork
: 'mainnet';
const parsedAccounts = accounts
.map((account, index) => {
const {
name,
serverUrl,
macaroonPath,
certificatePath,
macaroon: macaroonValue,
certificate,
password,
} = account;
const missingFields: string[] = [];
if (!name) missingFields.push('name');
if (!serverUrl) missingFields.push('server url');
if (!macaroonPath && !macaroonValue) missingFields.push('macaroon');
if (missingFields.length > 0) {
const text = missingFields.join(', ');
logger.error(`Account in index ${index} is missing the fields ${text}`);
return null;
}
if (!password && !masterPassword) {
logger.error(
`You must set a password for account ${name} or set a master password`
);
return null;
}
if (!certificatePath && !certificate)
logger.warn(
`No certificate for account ${name}. Make sure you don't need it to connect.`
);
const cert = certificate
? certificate
: (certificatePath && readFile(certificatePath)) || null;
const macaroon = macaroonValue ? macaroonValue : readFile(macaroonPath);
if (certificatePath && !cert)
logger.warn(
`No certificate for account ${name}. Make sure you don't need it to connect.`
);
if (!macaroon) {
logger.error(`No macarron found for account ${name}.`);
return null;
}
const id = getUUID(`${name}${serverUrl}${macaroon}${cert}`);
readAccounts.push(name);
return {
name,
id,
host: serverUrl,
macaroon,
cert,
password: password || masterPassword,
};
})
.map((account, index) =>
getParsedAccount(account, index, masterPassword, network)
)
.filter(Boolean);
const allAccounts = readAccounts.join(', ');
logger.info(`Server accounts that will be available: ${allAccounts}`);
logger.info(
`Server accounts that will be available: ${parsedAccounts
.map(({ name }) => name)
.join(', ')}`
);
return parsedAccounts;
};