#!/usr/bin/env python3 # Copyright (c) 2019 lightningd # Distributed under the BSD 3-Clause License, see the accompanying file LICENSE ############################################################################### # ZeroMQ publishing plugin for lightningd # # Using Twisted and txZMQ frameworks, this plugin binds to ZeroMQ endpoints and # publishes notification of all possible subscriptions that have been opted-in # for via lightningd launch parameter. # # This plugin doesn't interpret any of the content of the data which comes out # of lightningd, it merely passes the received JSON through as encoded UTF-8, # with the 'tag' being set to the Notification Type name (also encoded as # UTF-8). It follows that adding future possible subscriptions *should* be as # easy as appending it to NOTIFICATION_TYPE_NAMES below. # # The user-selectable configuration takes inspiration from the bitcoind ZeroMQ # integration. The endpoint must be explicitly given as an argument to enable # it. Also, the high water mark argument for the binding is set as an # additional launch option. # # Due to how the plugins must register via getmanifest, this will opt-in to all # subscriptions and ignore the messages from ones not bound to ZMQ endpoints. # Hence, there might be a minor performance impact from subscription messages # that result in no publish action. This can be mitigated by dropping # notifications that are not of interest to your ZeroMQ subscribers from # NOTIFICATION_TYPE_NAMES below. ############################################################################### import time import json import functools from twisted.internet import reactor from txzmq import ZmqEndpoint, ZmqEndpointType from txzmq import ZmqFactory from txzmq import ZmqPubConnection from pyln.client import Plugin ############################################################################### NOTIFICATION_TYPE_NAMES = ['channel_opened', 'connect', 'disconnect', 'invoice_payment', 'warning', 'forward_event', 'sendpay_success', 'sendpay_failure'] class NotificationType(): """ Wrapper for notification type string to generate the corresponding plugin option strings. By convention of lightningd, the cli options use dashes in place of rather than underscores or no spaces.""" def __init__(self, notification_type_name): self.notification_type_name = notification_type_name def __str__(self): return self.notification_type_name def endpoint_option(self): return "zmq-pub-{}".format(str(self).replace("_", "-")) def hwm_option(self): return "zmq-pub-{}-hwm".format(str(self).replace("_", "-")) NOTIFICATION_TYPES = [NotificationType(n) for n in NOTIFICATION_TYPE_NAMES] ############################################################################### class Publisher(): """ Holds the connection state and accepts incoming notifications that come from the subscription. If there is an associated publishing endpoint connected, it will encode and pass the contents of the notification. """ def __init__(self): self.factory = ZmqFactory() self.connection_map = {} def load_setup(self, setup): for e, s in setup.items(): endpoint = ZmqEndpoint(ZmqEndpointType.bind, e) ZmqPubConnection.highWaterMark = s['high_water_mark'] connection = ZmqPubConnection(self.factory, endpoint) for n in s['notification_type_names']: self.connection_map[n] = connection def publish_notification(self, notification_type_name, *args, **kwargs): if notification_type_name not in self.connection_map: return tag = notification_type_name.encode("utf8") message = json.dumps(kwargs).encode("utf8") connection = self.connection_map[notification_type_name] connection.publish(message, tag=tag) publisher = Publisher() ############################################################################### ZMQ_TRANSPORT_PREFIXES = ['tcp://', "ipc://", 'inproc://', "pgm://", "epgm://"] class Setup(): """ Does some light validation of the plugin option input and generates a dictionary to configure the Twisted and ZeroMQ setup """ def _at_least_one_binding(options): n_bindings = sum(1 for o, v in options.items() if not o.endswith("-hwm") and v != "null") return n_bindings > 0 def _iter_endpoints_not_ok(options): for nt in NOTIFICATION_TYPES: endpoint_opt = nt.endpoint_option() endpoint = options[endpoint_opt] if endpoint != "null": if len([1 for prefix in ZMQ_TRANSPORT_PREFIXES if endpoint.startswith(prefix)]) != 0: continue yield endpoint def check_option_warnings(options, plugin): if not Setup._at_least_one_binding(options): plugin.log("No zmq publish sockets are bound as per launch args", level='warn') for endpoint in Setup._iter_endpoints_not_ok(options): plugin.log(("Endpoint option {} doesn't appear to be recognized" ).format(endpoint), level='warn') ########################################################################### def _iter_endpoint_setup(options): for nt in NOTIFICATION_TYPES: endpoint_opt = nt.endpoint_option() if options[endpoint_opt] == "null": continue endpoint = options[endpoint_opt] hwm_opt = nt.hwm_option() hwm = int(options[hwm_opt]) yield endpoint, nt, hwm def get_setup_dict(options): setup = {} for e, nt, hwm in Setup._iter_endpoint_setup(options): if e not in setup: setup[e] = {'notification_type_names': [], 'high_water_mark': hwm} setup[e]['notification_type_names'].append(str(nt)) # use the lowest high water mark given for the endpoint setup[e]['high_water_mark'] = min( setup[e]['high_water_mark'], hwm) return setup ########################################################################### def log_setup_dict(setup, plugin): for e, s in setup.items(): m = ("Endpoint {} will get events from {} subscriptions " "published with high water mark {}") m = m.format(e, s['notification_type_names'], s['high_water_mark']) plugin.log(m) ############################################################################### plugin = Plugin() @plugin.init() def init(options, configuration, plugin, **kwargs): Setup.check_option_warnings(options, plugin) setup_dict = Setup.get_setup_dict(options) Setup.log_setup_dict(setup_dict, plugin) reactor.callFromThread(publisher.load_setup, setup_dict) def on_notification(notification_type_name, plugin, *args, **kwargs): if len(args) != 0: plugin.log("got unexpected args: {}".format(args), level="warn") reactor.callFromThread(publisher.publish_notification, notification_type_name, *args, **kwargs) DEFAULT_HIGH_WATER_MARK = 1000 for nt in NOTIFICATION_TYPES: # subscribe to all notifications on = functools.partial(on_notification, str(nt)) on.__annotations__ = {} # needed to please Plugin._coerce_arguments() plugin.add_subscription(str(nt), on) # zmq socket binding option endpoint_opt = nt.endpoint_option() endpoint_desc = "Enable publish {} info to ZMQ socket endpoint".format(nt) plugin.add_option(endpoint_opt, None, endpoint_desc, opt_type='string') # high water mark option hwm_opt = nt.hwm_option() hwm_desc = ("Set publish {} info message high water mark " "(default: {})".format(nt, DEFAULT_HIGH_WATER_MARK)) plugin.add_option(hwm_opt, DEFAULT_HIGH_WATER_MARK, hwm_desc, opt_type='int') ############################################################################### def plugin_thread(): plugin.run() reactor.callFromThread(reactor.stop) reactor.callInThread(plugin_thread) reactor.run()