Skip to content

Commit

Permalink
feat: add backup daemon
Browse files Browse the repository at this point in the history
  • Loading branch information
michael1011 committed Jun 18, 2019
1 parent d11bee6 commit 1af6c81
Show file tree
Hide file tree
Showing 6 changed files with 297 additions and 1 deletion.
74 changes: 74 additions & 0 deletions bin/xu-backup
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env node

const Backup = require('../dist/backup/Backup').default;

const { argv } = require('yargs')
.options({
backupdir: {
describe: 'Data directory for backups',
type: 'string',
alias: 'b',
},
xudir: {
describe: 'Data directory for xud',
type: 'string',
alias: 'x',
},
loglevel: {
describe: 'Verbosity of the logger',
type: 'string',
alias: 'l',
},
logpath: {
describe: 'Path to the log file',
type: 'string',
},
logdateformat: {
describe: 'Format of the logger date',
type: 'string',
},
'lnd.[currency].certpath': {
describe: 'Path to the SSL certificate for lnd',
type: 'string',
},
'lnd.[currency].cltvdelta': {
describe: 'CLTV delta for the final timelock',
type: 'number',
},
'lnd.[currency].disable': {
describe: 'Disable lnd integration',
type: 'boolean',
default: undefined,
},
'lnd.[currency].host': {
describe: 'Host of the lnd gRPC interface',
type: 'string',
},
'lnd.[currency].macaroonpath': {
describe: 'Path of the admin macaroon for lnd',
type: 'string',
},
'lnd.[currency].nomacaroons': {
describe: 'Whether to disable macaroons for lnd',
type: 'boolean',
default: undefined,
},
'lnd.[currency].port': {
describe: 'Port for the lnd gRPC interface',
type: 'number',
},
'raiden.database': {
describe: 'Path to the database of Raiden',
type: 'string',
},
});

// delete non-config keys from argv
delete argv._;
delete argv.version;
delete argv.help;
delete argv.$0;

const backup = new Backup();

backup.start(argv);
1 change: 1 addition & 0 deletions lib/Logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export enum Context {
Raiden = 'RAIDEN',
Swaps = 'SWAPS',
Http = 'HTTP',
Backup= 'BACKUP',
}

type Loggers = {
Expand Down
139 changes: 139 additions & 0 deletions lib/backup/Backup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { createHash } from 'crypto';
import Config from '../Config';
import Logger, { Context } from '../Logger';
import LndClient from '../lndclient/LndClient';

class Backup {
private logger!: Logger;

private backupDir!: string;

private config = new Config();

public start = async (args: { [argName: string]: any }) => {
await this.config.load(args);

this.backupDir = args.backupdir || this.getDefaultBackupDir();

if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir);
}

this.logger = new Logger({
context: Context.Backup,
level: this.config.loglevel,
filename: this.config.logpath,
instanceId: this.config.instanceid,
dateFormat: this.config.logdateformat,
});

// Start the LND SCB subscriptions
for (const currency in this.config.lnd) {
const lndConfig = this.config.lnd[currency]!;

// Ignore the LND client if it is disabled or not configured
if (!lndConfig.disable && Object.entries(lndConfig).length !== 0) {
const lndClient = new LndClient(
lndConfig,
currency,
Logger.DISABLED_LOGGER,
);

await lndClient.init();

this.logger.verbose(`Writing initial ${lndClient.currency} LND channel backup`);

const channelBackup = await lndClient.exportAllChannelBackup();
this.writeLndBackup(lndClient.currency, channelBackup);

this.listenToChannelBackups(lndClient);

lndClient.subscribeChannelBackups();
this.logger.verbose(`Listening to ${currency} LND channel backups`);
}
}

// Start the Raiden database filewatcher
if (args.raiden && args.raiden.database !== undefined) {
const raidenDb = args.raiden.database;

if (fs.existsSync(raidenDb)) {
let previousRaidenMd5 = '';

this.logger.verbose('Writing initial Raiden database backup');
const { content, hash } = this.readRaidenDatabase(raidenDb);

previousRaidenMd5 = hash;
this.writeBackup('raiden', content);

fs.watch(raidenDb, { persistent: true, recursive: false }, (event: string) => {
if (event === 'change') {
const { content, hash } = this.readRaidenDatabase(raidenDb);

// Compare the MD5 hash of the current content of the file with hash of the content when
// it was backed up the last time to ensure that the content of the file has changed
if (hash !== previousRaidenMd5) {
this.logger.debug('Raiden database changed');

previousRaidenMd5 = hash;
this.writeBackup('raiden', content);
}
}
}) ;

this.logger.verbose('Listening for changes to the Raiden database');
} else {
this.logger.error(`Could not find database file of Raiden: ${raidenDb}`);
}
} else {
this.logger.warn('Raiden database file not specified');
}

this.logger.info('Started backup daemon');
}

private listenToChannelBackups = (lndClient: LndClient) => {
lndClient.on('channelBackup', (channelBackup) => {
this.logger.debug(`New ${lndClient.currency} channel backup`);
this.writeLndBackup(lndClient.currency, channelBackup);
});
}

private readRaidenDatabase = (path: string): { content: string, hash: string } => {
const content = fs.readFileSync(path);

return {
content: content.toString('utf-8'),
hash: createHash('md5').update(content).digest('base64'),
};
}

private writeLndBackup = (currency: string, channelBackup: string) => {
this.writeBackup(`lnd-${currency}`, channelBackup);
}

private writeBackup = (fileName: string, data: string) => {
try {
fs.writeFileSync(
path.join(this.backupDir, fileName),
data,
);
} catch (error) {
this.logger.error(`Could not write backup file: ${error}`);
}
}

private getDefaultBackupDir = () => {
switch (os.platform()) {
case 'win32':
return path.join(process.env.LOCALAPPDATA!, 'Xud Backup');
default:
return path.join(process.env.HOME!, '.xud-backup');
}
}
}

export default Backup;
25 changes: 25 additions & 0 deletions lib/lndclient/LndClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ interface InvoicesMethodIndex extends InvoicesClient {
interface LndClient {
on(event: 'connectionVerified', listener: (newIdentifier?: string) => void): this;
on(event: 'htlcAccepted', listener: (rHash: string, amount: number) => void): this;
on(event: 'channelBackup', listener: (channelBackup: string) => void): this;
emit(event: 'connectionVerified', newIdentifier?: string): boolean;
emit(event: 'htlcAccepted', rHash: string, amount: number): boolean;
emit(event: 'channelBackup', channelBackup: string): boolean;
}

/** A class representing a client to interact with lnd. */
Expand All @@ -44,6 +46,7 @@ class LndClient extends SwapClient {
private credentials!: ChannelCredentials;
private identityPubKey?: string;
private channelSubscription?: ClientReadableStream<lndrpc.ChannelEventUpdate>;
private channelBackupSubscription?: ClientReadableStream<lndrpc.ChanBackupSnapshot>;
private invoiceSubscriptions = new Map<string, ClientReadableStream<lndrpc.Invoice>>();

/**
Expand Down Expand Up @@ -476,6 +479,13 @@ class LndClient extends SwapClient {
}
}

public exportAllChannelBackup = async () => {
const request = new lndrpc.ChanBackupExportRequest();
const response = await this.unaryCall<lndrpc.ChanBackupExportRequest, lndrpc.ChanBackupSnapshot>('exportAllChannelBackups', request);

return response.getMultiChanBackup()!.getMultiChanBackup_asB64();
}

private addHoldInvoice = (request: lndinvoices.AddHoldInvoiceRequest): Promise<lndinvoices.AddHoldInvoiceResp> => {
return this.unaryInvoiceCall<lndinvoices.AddHoldInvoiceRequest, lndinvoices.AddHoldInvoiceResp>('addHoldInvoice', request);
}
Expand Down Expand Up @@ -523,6 +533,21 @@ class LndClient extends SwapClient {
});
}

/**
* Subscribes to channel backups
*/
public subscribeChannelBackups = () => {
if (this.channelBackupSubscription) {
this.channelBackupSubscription.cancel();
}

this.channelBackupSubscription = this.lightning.subscribeChannelBackups(new lndrpc.ChannelBackupSubscription(), this.meta)
.on('data', (backupSnapshot: lndrpc.ChanBackupSnapshot) => {
const multiBackup = backupSnapshot.getMultiChanBackup()!;
this.emit('channelBackup', multiBackup.getMultiChanBackup_asB64().toString());
});
}

/**
* Attempts to close an open channel.
*/
Expand Down
57 changes: 57 additions & 0 deletions test/jest/Backup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import fs from 'fs';
import { removeSync } from 'fs-extra';
import path from 'path';
import Backup from '../../lib/backup/Backup';

const backupdir = 'backup-test';

let channelBackupCallback: any;

const onListenerMock = jest.fn((_, callback) => {
channelBackupCallback = callback;
});

jest.mock('../../lib/lndclient/LndClient', () => {
return jest.fn().mockImplementation(() => {
return {
on: onListenerMock,
currency: 'BTC',
init: () => Promise.resolve(),
subscribeChannelBackups: () => Promise.resolve(),
};
});
});

describe('Backup', () => {
test('should resolve backup directory', () => {
const backup = new Backup();

expect(backup['getDefaultBackupDir']()).toContainEqual('/');
});

test('should write LND backups to backup directory', async () => {
const backup = new Backup();

const channelBackup = 'btcBackup';

await backup.start({
backupdir,
loglevel: 'error',
});

expect(onListenerMock).toBeCalledTimes(2);

channelBackupCallback(channelBackup);

expect(
fs.readFileSync(
path.join(backupdir, 'lnd-BTC'),
'utf-8',
),
).toEqual(channelBackup);
});

afterAll(() => {
removeSync(backupdir);
});
});
2 changes: 1 addition & 1 deletion test/jest/RaidenClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe('RaidenClient', () => {

afterEach(async () => {
jest.clearAllMocks();
await raiden.close();
raiden.close();
});

test('sendPayment removes 0x from secret', async () => {
Expand Down

0 comments on commit 1af6c81

Please sign in to comment.