mirror of
https://github.com/apotdevin/thunderhub.git
synced 2024-11-19 09:50:03 +01:00
* 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:
parent
27b5e832b4
commit
56e6b964c9
59
README.md
59
README.md
@ -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.
|
||||
|
209
server/helpers/__tests__/fileHelpers.test.ts
Normal file
209
server/helpers/__tests__/fileHelpers.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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,43 +210,35 @@ 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;
|
||||
|
||||
if (!preAccounts || preAccounts.length <= 0) {
|
||||
logger.warn(`Account config found at path ${filePath} but had no accounts`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { masterPassword, accounts } = hashPasswords(
|
||||
hashed,
|
||||
accountConfig,
|
||||
filePath
|
||||
);
|
||||
|
||||
const readAccounts = [];
|
||||
|
||||
const parsedAccounts = accounts
|
||||
.map((account, index) => {
|
||||
export const getParsedAccount = (
|
||||
account: AccountType,
|
||||
index: number,
|
||||
masterPassword: string,
|
||||
defaultNetwork: BitcoinNetwork
|
||||
): ParsedAccount | null => {
|
||||
const {
|
||||
name,
|
||||
serverUrl,
|
||||
network,
|
||||
lndDir,
|
||||
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 (!lndDir && !macaroonPath && !macaroonValue) {
|
||||
missingFields.push('macaroon');
|
||||
}
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
const text = missingFields.join(', ');
|
||||
@ -195,6 +246,11 @@ export const getAccounts = (filePath: string) => {
|
||||
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`
|
||||
@ -202,30 +258,23 @@ export const getAccounts = (filePath: string) => {
|
||||
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)
|
||||
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(`No macarron found for account ${name}.`);
|
||||
logger.error(
|
||||
`Account ${name} has neither lnd directory, macaroon nor macaroon path specified.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = getUUID(`${name}${serverUrl}${macaroon}${cert}`);
|
||||
|
||||
readAccounts.push(name);
|
||||
|
||||
return {
|
||||
name,
|
||||
id,
|
||||
@ -234,11 +283,40 @@ export const getAccounts = (filePath: string) => {
|
||||
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 { defaultNetwork, masterPassword, accounts } = hashPasswords(
|
||||
hashed,
|
||||
config,
|
||||
filePath
|
||||
);
|
||||
|
||||
const network: BitcoinNetwork = isValidNetwork(defaultNetwork)
|
||||
? defaultNetwork
|
||||
: 'mainnet';
|
||||
|
||||
const parsedAccounts = accounts
|
||||
.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;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user