mirror of
https://github.com/apotdevin/thunderhub.git
synced 2025-02-23 14:40:27 +01:00
* chore: 🔧 remove client * chore: 🔧 change cookie name * chore: 🔧 remove auth param * chore: 🔧 remove auth components * chore: 🔧 add getaccount query * fix: 🐛 tests * chore: 🔧 get account * chore: 🔧 status check * chore: 🔧 remove log * chore: 🔧 update apollo client * refactor: ♻️ server side props * chore: 🔧 ssr queries * chore: 🔧 more ssr queries * chore: 🔧 type check * chore: 🔧 increase nodeinfo limit Co-authored-by: apotdevin <apotdevincab@gmail.com>
421 lines
10 KiB
TypeScript
421 lines
10 KiB
TypeScript
import fs from 'fs';
|
|
import crypto from 'crypto';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { logger } from 'server/helpers/logger';
|
|
import yaml from 'js-yaml';
|
|
import bcrypt from 'bcryptjs';
|
|
import { AccountType as ContextAccountType } from 'server/types/apiTypes';
|
|
import { getUUID } from './auth';
|
|
|
|
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 | null;
|
|
macaroon?: string;
|
|
certificate?: string;
|
|
};
|
|
type ParsedAccount = {
|
|
name: string;
|
|
id: string;
|
|
socket: string;
|
|
macaroon: string;
|
|
cert: string;
|
|
password: string;
|
|
};
|
|
type AccountConfigType = {
|
|
hashed: boolean | null;
|
|
masterPassword: string | null;
|
|
defaultNetwork: string | null;
|
|
accounts: AccountType[];
|
|
};
|
|
|
|
const isValidNetwork = (network: string | null): network is BitcoinNetwork =>
|
|
network === 'mainnet' || network === 'regtest' || network === 'testnet';
|
|
|
|
export const PRE_PASS_STRING = 'thunderhub-';
|
|
|
|
export const readFile = (
|
|
filePath: string,
|
|
encoding: EncodingType = 'hex'
|
|
): string | null => {
|
|
if (filePath === '') {
|
|
return null;
|
|
}
|
|
|
|
const fileExists = fs.existsSync(filePath);
|
|
|
|
if (!fileExists) {
|
|
logger.error(`No file found at path: ${filePath}`);
|
|
return null;
|
|
} else {
|
|
try {
|
|
const file = fs.readFileSync(filePath, encoding);
|
|
return file;
|
|
} catch (err) {
|
|
logger.error('Something went wrong while reading the file: \n' + err);
|
|
return null;
|
|
}
|
|
}
|
|
};
|
|
|
|
export const parseYaml = (filePath: string): AccountConfigType | null => {
|
|
if (filePath === '') {
|
|
return null;
|
|
}
|
|
|
|
const yamlConfig = readFile(filePath, 'utf-8');
|
|
|
|
if (!yamlConfig) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const yamlObject = yaml.safeLoad(yamlConfig);
|
|
// TODO: validate this, before returning?
|
|
return yamlObject as AccountConfigType;
|
|
} catch (err) {
|
|
logger.error(
|
|
'Something went wrong while parsing the YAML config file: \n' + err
|
|
);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const saveHashedYaml = (config: AccountConfigType, filePath: string): void => {
|
|
if (filePath === '' || !config) return;
|
|
|
|
logger.info('Saving new yaml file with hashed passwords');
|
|
|
|
try {
|
|
const yamlString = yaml.safeDump(config);
|
|
fs.writeFileSync(filePath, yamlString);
|
|
logger.info('Succesfully saved');
|
|
} catch (error) {
|
|
logger.error(
|
|
'Error saving yaml file with hashed passwords. Passwords are still in cleartext on your server.'
|
|
);
|
|
}
|
|
};
|
|
|
|
export const hashPasswords = (
|
|
isHashed: boolean,
|
|
config: AccountConfigType,
|
|
filePath: string
|
|
): AccountConfigType => {
|
|
// Return early when passwords are already hashed
|
|
if (isHashed) return config;
|
|
|
|
let hasChanged = false;
|
|
|
|
const cloned = { ...config };
|
|
|
|
let hashedMasterPassword = config?.masterPassword;
|
|
|
|
if (
|
|
hashedMasterPassword &&
|
|
hashedMasterPassword.indexOf(PRE_PASS_STRING) < 0
|
|
) {
|
|
hasChanged = true;
|
|
hashedMasterPassword = `${PRE_PASS_STRING}${bcrypt.hashSync(
|
|
hashedMasterPassword,
|
|
12
|
|
)}`;
|
|
}
|
|
|
|
cloned.masterPassword = hashedMasterPassword;
|
|
|
|
const hashedAccounts: AccountType[] = [];
|
|
|
|
for (let i = 0; i < config.accounts.length; i++) {
|
|
const account: AccountType = config.accounts[i];
|
|
if (account.password) {
|
|
let hashedPassword = account.password;
|
|
|
|
if (hashedPassword.indexOf(PRE_PASS_STRING) < 0) {
|
|
hasChanged = true;
|
|
hashedPassword = `${PRE_PASS_STRING}${bcrypt.hashSync(
|
|
account.password,
|
|
12
|
|
)}`;
|
|
}
|
|
|
|
hashedAccounts.push({ ...account, password: hashedPassword });
|
|
} else {
|
|
hashedAccounts.push(account);
|
|
}
|
|
}
|
|
|
|
cloned.accounts = hashedAccounts;
|
|
|
|
hasChanged && saveHashedYaml(cloned, filePath);
|
|
|
|
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 | null => {
|
|
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): ContextAccountType[] => {
|
|
if (filePath === '') {
|
|
logger.verbose('No account config file path provided');
|
|
return [];
|
|
}
|
|
|
|
const accountConfig = parseYaml(filePath);
|
|
if (!accountConfig) {
|
|
logger.info(`No account config file found at path ${filePath}`);
|
|
return [];
|
|
}
|
|
return getAccountsFromYaml(accountConfig, filePath) as ContextAccountType[];
|
|
};
|
|
|
|
export const getParsedAccount = (
|
|
account: AccountType,
|
|
index: number,
|
|
masterPassword: string | null,
|
|
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: name || '',
|
|
id,
|
|
socket: serverUrl || '',
|
|
macaroon,
|
|
cert: 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 || false,
|
|
config,
|
|
filePath
|
|
);
|
|
|
|
const network: BitcoinNetwork = isValidNetwork(defaultNetwork)
|
|
? defaultNetwork
|
|
: 'mainnet';
|
|
|
|
const parsedAccounts = accounts
|
|
.map((account, index) =>
|
|
getParsedAccount(account, index, masterPassword, network)
|
|
)
|
|
.filter(Boolean);
|
|
|
|
logger.info(
|
|
`Server accounts that will be available: ${parsedAccounts
|
|
.map(account => account?.name)
|
|
.join(', ')}`
|
|
);
|
|
|
|
return parsedAccounts;
|
|
};
|
|
|
|
export const readMacaroons = (macaroonPath: string): string | null => {
|
|
if (macaroonPath === '') {
|
|
logger.verbose('No macaroon path provided');
|
|
return null;
|
|
}
|
|
|
|
const adminExists = fs.existsSync(`${macaroonPath}/admin.macaroon`);
|
|
|
|
if (!adminExists) {
|
|
logger.error(
|
|
`No admin.macaroon file found at path: ${macaroonPath}/admin.macaroon`
|
|
);
|
|
return null;
|
|
} else {
|
|
try {
|
|
const ssoAdmin = fs.readFileSync(`${macaroonPath}/admin.macaroon`, 'hex');
|
|
return ssoAdmin;
|
|
} catch (err) {
|
|
logger.error(
|
|
'Something went wrong while reading the admin.macaroon: \n' + err
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
};
|
|
|
|
export const createDirectory = (dirname: string) => {
|
|
const initDir = path.isAbsolute(dirname) ? path.sep : '';
|
|
dirname.split(path.sep).reduce((parentDir, childDir) => {
|
|
const curDir = path.resolve(parentDir, childDir);
|
|
try {
|
|
if (!fs.existsSync(curDir)) {
|
|
fs.mkdirSync(curDir);
|
|
}
|
|
} catch (err) {
|
|
if (err.code !== 'EEXIST') {
|
|
if (err.code === 'ENOENT') {
|
|
throw new Error(
|
|
`ENOENT: No such file or directory, mkdir '${dirname}'. Ensure that path separator is '${
|
|
os.platform() === 'win32' ? '\\\\' : '/'
|
|
}'`
|
|
);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
return curDir;
|
|
}, initDir);
|
|
};
|
|
|
|
export const readCookie = (cookieFile: string): string | null => {
|
|
if (cookieFile === '') {
|
|
logger.verbose('No cookie path provided');
|
|
return null;
|
|
}
|
|
|
|
const exists = fs.existsSync(cookieFile);
|
|
if (exists) {
|
|
try {
|
|
logger.verbose(`Found cookie at path ${cookieFile}`);
|
|
const cookie = fs.readFileSync(cookieFile, 'utf-8');
|
|
return cookie;
|
|
} catch (err) {
|
|
logger.error('Something went wrong while reading cookie: \n' + err);
|
|
throw new Error(err);
|
|
}
|
|
} else {
|
|
try {
|
|
const dirname = path.dirname(cookieFile);
|
|
createDirectory(dirname);
|
|
fs.writeFileSync(cookieFile, crypto.randomBytes(64).toString('hex'));
|
|
|
|
logger.info(`Cookie created at directory: ${dirname}`);
|
|
|
|
const cookie = fs.readFileSync(cookieFile, 'utf-8');
|
|
return cookie;
|
|
} catch (err) {
|
|
logger.error('Something went wrong while reading the cookie: \n' + err);
|
|
throw new Error(err);
|
|
}
|
|
}
|
|
};
|
|
|
|
export const refreshCookie = (cookieFile: string) => {
|
|
try {
|
|
logger.verbose('Refreshing cookie for next authentication');
|
|
fs.writeFileSync(cookieFile, crypto.randomBytes(64).toString('hex'));
|
|
} catch (err) {
|
|
logger.error('Something went wrong while refreshing cookie: \n' + err);
|
|
throw new Error(err);
|
|
}
|
|
};
|