@node-dlc/wire
Version:
Lightning Network Wire Protocol
448 lines (391 loc) • 14.7 kB
text/typescript
import { BitField } from '@node-dlc/common';
import { ILogger } from '@node-dlc/logger';
import * as noise from '@node-dlc/noise';
import { NoiseSocket } from '@node-dlc/noise';
import assert from 'assert';
import { Readable } from 'stream';
import { InitFeatureFlags } from './flags/InitFeatureFlags';
import * as MessageFactory from './MessageFactory';
import { InitMessage } from './messages/InitMessage';
import { IWireMessage } from './messages/IWireMessage';
import { PeerState } from './PeerState';
import { PingPongState } from './PingPongState';
export interface IMessageSender {
send(buf: Buffer): void;
sendMessage(msg: IWireMessage): void;
}
export interface IMessageReceiver {
read(): IWireMessage;
on(event: 'readable', listener: () => void): this;
off(event: 'readable', listener: () => void): this;
}
export type IMessageSenderReceiver = IMessageSender & IMessageReceiver;
export interface IPeer extends IMessageSenderReceiver {
send(buf: Buffer): void;
sendMessage(msg: IWireMessage): void;
disconnect(): void;
read(): IWireMessage;
on(event: 'readable', listener: () => void): this;
on(event: 'error', listener: (err: Error) => void): this;
off(event: 'readable', listener: () => void): this;
off(event: 'error', listener: (err: Error) => void): this;
}
/**
* Peer is an EventEmitter that layers the Lightning Network wire
* protocol ontop of an @node-dlc/noise NoiseSocket.
*
* Peer itself is a state-machine with three states:
* 1. pending
* 2. awaiting_peer_init
* 3. ready
*
* The Peer instance starts in `pending` until the underlying NoiseSocket
* has connected.
*
* It then immediately sends the InitMessage as specified in the Peer
* constructor.
*
* At this point, the Peer transitions to `awaiting_peer_init`.
*
* Once the remote peer has sent its InitMessage, the state is
* transitioned to `ready` and the Peer can be begin sending and
* receiving messages.
*
* Once the peer is in the `ready` state it will begin emitting `message`
* events when it receives new messages from the peer.
*
* The Peer will also start a PingPong state machine to manage sending
* and receiving Pings and Pongs as defined in BOLT01
*
* A choice (probably wrongly) was made to make Peer an EventEmitter
* instead of a DuplexStream operating in object mode. We need to keep
* the noise socket in flowing mode (instead of paused) because we will
* not know the length of messages until after we have deserialized the
* message. This makes it a challenge to implement a DuplexStream that
* emits objects (such as messages).
*
* @emits ready the underlying socket has performed its handshake and
* initialization message swap has occurred.
*
* @emits message a new message has been received. Only sent after the
* `ready` event has fired.
*
* @emits rawmessage outputs the message as a raw buffer instead of
* a deserialized message.
*
* @emits error emitted when there is an error processing a message.
* The underlying socket will be closed after this event is emitted.
*
* @emits close emitted when the connection to the peer has completedly
* closed.
*
* @emits open emmited when the connection to the peer has been established
* after the handshake has been performed
*
* @emits end emitted when the connection to the peer is ending.
*/
export class Peer extends Readable implements IPeer {
public static states = PeerState;
public state: PeerState = PeerState.Disconnected;
public socket: NoiseSocket;
public messageCounter = 0;
public pingPongState: PingPongState;
public logger: ILogger;
public remoteFeatures: BitField<InitFeatureFlags>;
public remoteChains: Buffer[];
public isInitiator = false;
public reconnectTimeoutMs = 15000;
private _id: string;
private _rpk: Buffer;
private _host: string;
private _port: number;
private _reconnectHandle: NodeJS.Timeout;
constructor(
readonly ls: Buffer,
readonly localFeatures: BitField<InitFeatureFlags>,
readonly localChains: Buffer[],
logger: ILogger,
readonly highWaterMark: number = 2048,
) {
super({ objectMode: true, highWaterMark });
this.pingPongState = new PingPongState(this);
this.logger = logger;
}
public get id(): string {
return this._id;
}
public get pubkey(): Buffer {
return this._rpk;
}
public get pubkeyHex(): string {
return this._rpk ? this._rpk.toString('hex') : undefined;
}
/**
* Connect to the remote peer and binds socket events into the Peer.
*/
public connect(rpk: Buffer, host: string, port: number) {
// construct logger specific to the peer
this._rpk = rpk;
this._id = this._rpk.slice(0, 8).toString('hex');
this.logger = this.logger.sub('peer', this._id);
this.logger.info('connecting to peer');
this.isInitiator = true;
// store these values if we need to use them for a reconnection event.
this._host = host;
this._port = port;
// create the socket and initiate the connection
this.socket = noise.connect({
ls: this.ls,
host: this._host,
port: this._port,
rpk: this._rpk,
logger: this.logger,
});
this.socket.on('readable', this._onSocketReadable.bind(this));
this.socket.on('ready', this._onSocketReady.bind(this));
this.socket.on('close', this._onSocketClose.bind(this));
this.socket.on('error', this._onSocketError.bind(this));
}
/**
*
* @param socket
*/
public attach(socket: NoiseSocket) {
this.isInitiator = false;
this.socket = socket;
this.socket.on('readable', this._onSocketReadable.bind(this));
this.socket.on('ready', this._onSocketReady.bind(this));
this.socket.on('close', this._onSocketClose.bind(this));
this.socket.on('error', this._onSocketError.bind(this));
// Once the socket is ready, we have performed the handshake which allows
// us to ascertain the identity of the connecting node. Now that we have
// that identity ascertained we can construct the instance specific logger
this.socket.once('ready', () => {
this._rpk = socket.rpk;
this._id = this._rpk.slice(0, 8).toString('hex');
this.logger = this.logger.sub('peer', this._id);
this.logger.info('peer connected');
});
}
/**
* Writes data on the NoiseSocket. This method allows custom
* serialization of methods. Use `sendMessage` to send a message
* using the default message serialization.
* @param buf
*/
public send(buf: Buffer): boolean {
assert.ok(this.state === PeerState.Ready, new Error('Peer is not ready'));
this.emit('sending', buf);
return this.socket.write(buf);
}
/**
* Writes the message on the NoiseSocket using the default
* serialization properties
*/
public sendMessage(m: IWireMessage): boolean {
assert.ok(this.state === PeerState.Ready, new Error('Peer is not ready'));
const buf = m.serialize() as Buffer;
this.emit('sending', buf);
return this.socket.write(buf);
}
/**
* Closes the socket
*/
public disconnect() {
this.logger.info('disconnecting');
this.state = PeerState.Disconnecting;
this.socket.end();
}
/**
* Reconnects the socket
*/
public reconnect() {
this.socket.end();
}
/////////////////////////////////////////////////////////
private _onSocketReady() {
// now that we're connected, we need to wait for the remote reply
// before any other messages can be receieved or sent
this.state = PeerState.AwaitingPeerInit;
this.logger.debug('state: awaiting_peer_init');
// blast off our init message
this.emit('open');
this._sendInitMessage();
}
private _onSocketClose() {
this.logger.debug('socket closed');
// Clear any existing reconnection handles. We want the logic in
// this method, and the current state of the peer to dictate
// what should happen.
clearTimeout(this._reconnectHandle);
// Clear the ping/pong status
if (this.pingPongState) this.pingPongState.onDisconnecting();
// If socket closed because there was a request to disconnect
// the underlying socket, we will emit a close event and mark
// the state as disconnected.
if (this.state === PeerState.Disconnecting) {
this.emit('close');
this.state = PeerState.Disconnected;
this.logger.debug('permanently disconnected');
return;
}
// Update the state to indicate that the peer is no longer connected.
// If we don't do this then upon reconnection the Peer will be in
// invalid state and will not send the initialization message.
this.state = PeerState.Disconnected;
this.logger.debug('state: disconnected');
// If the disconnection was not intentional, we will initiate
// a reconnection event by creating a reconnection handle
// and delaying the connection event by the reconnectTimeoutMs
// value.
// This is likely to originate from two sources:
// 1) A reconnection was requeted by the ping/pong manager because
// receipt of a pong message timed out
// 2) A network error occurred on a subsequent connection event
// which triggered a socket close event. This can happen if a connection
// is disrupted for a longer period of time. The reconnection logic
// should continue to fire until a connection can be established.
if (this.isInitiator) {
this.logger.debug(
`reconnecting in ${(this.reconnectTimeoutMs / 1000).toFixed(1)}s`,
);
this._reconnectHandle = setTimeout(() => {
this.connect(this._rpk, this._host, this._port);
}, this.reconnectTimeoutMs);
}
}
private _onSocketError(err) {
// emit what error we recieved
this.emit('error', err);
}
public _onSocketReadable() {
this.logger.trace('socket is readable');
try {
let cont = true;
while (cont) {
const raw: Buffer = this.socket.read() as Buffer;
if (!raw) return;
if (this.state === PeerState.AwaitingPeerInit) {
this._processPeerInitMessage(raw);
} else {
const m = this._processMessage(raw);
cont = this.push(m);
}
}
} catch (err) {
// we have a problem, kill connectinon with the client
this.logger.error(err);
this.socket.end();
this.emit('error', err);
return;
}
}
public _read() {
// Trigger a read but wait until the end of the event loop.
// This is necessary when reading in paused mode where
// _read was triggered by stream.read() originating inside
// a "readable" event handler. Attempting to push more data
// synchronously will not trigger another "readable" event.
setImmediate(() => this._onSocketReadable());
}
/**
* Sends the initialization message to the peer. This message
* does not matter if it is sent before or after the peer sends
* there message.
*/
private _sendInitMessage() {
// construct the init message
const msg = new InitMessage();
msg.features = this.localFeatures;
msg.chainHashes = this.localChains;
// fire off the init message to the peer
const payload = msg.serialize();
this.emit('sending', payload);
this.socket.write(payload);
this.logger.debug('init message sent');
}
/**
* Processes the initialization message sent by the remote peer.
* Once this is successfully completed, the state is transitioned
* to `active`
*/
private _processPeerInitMessage(raw: Buffer) {
// deserialize message
const m = MessageFactory.deserialize(raw) as InitMessage;
if (this.logger) {
const features: InitFeatureFlags[] = m.features.flags();
this.logger.info('peer initialized with features', features);
}
// ensure we got an InitMessagee
assert.ok(m instanceof InitMessage, new Error('Expecting InitMessage'));
// store the init messagee in case we need to refer to it
this.remoteFeatures = m.features;
// capture the local chains
this.remoteChains = m.chainHashes;
// validate remote chains and if we are unawares of any. We do this by
// first looking to see if there is any match on the chains. If there is
// no match and both the remote and our local node declare that we are
// monitoring a specific chain then we will abort the connnection
let hasChain = false;
for (const remoteChain of this.remoteChains) {
for (const localChain of this.localChains) {
if (remoteChain.equals(localChain)) hasChain = true;
}
}
if (!hasChain && this.remoteChains.length && this.localChains.length) {
this.logger.trace('remote chains', ...this.remoteChains);
this.logger.trace('local chains', ...this.localChains);
this.logger.warn(
'remote node does not support any known chains, aborting',
);
this.disconnect();
return;
}
// we need to be sure that the remote node supports required features
// that we care about. If the remote node does not support these feature
// we will disconnect.
for (const feature of this.localFeatures.flags()) {
// we can skip odd features since they are optional
if (feature % 2 === 1) continue;
// for even (compulsory) features, we check if the remote node is
// signalling either optional or compulsory support. This code
// makes the assumption that the even features are always first
if (
!(
this.remoteFeatures.isSet(feature) ||
this.remoteFeatures.isSet(feature + 1)
)
) {
this.disconnect();
return;
}
}
// start other state now that peer is initialized
this.pingPongState.start();
// transition state to ready
this.state = PeerState.Ready;
// emit ready event
this.emit('ready');
}
/**
* Process the raw message sent by the peer. These messages are
* processed after the initialization message has been received.
*/
private _processMessage(raw: Buffer): IWireMessage {
// increment counter first so we know exactly how many messages
// have been received by the peer regardless of whether they
// could be processed
this.messageCounter += 1;
// emit the rawmessage event first so that if there is a
// deserialization problem there is a chance that we were
// able to capture the raw message for further testing
this.emit('rawmessage', raw);
// deserialize the message
const m = MessageFactory.deserialize(raw);
// ensure pingpong state is updated
if (m) {
this.pingPongState.onMessage(m);
}
return m;
}
}