From 5d062830bae59e7d0ce7615aebc20f94a36b72d7 Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Sun, 25 Jul 2021 17:52:52 +0930 Subject: [PATCH] commando: reckless? Try going commando! Signed-off-by: Rusty Russell --- commando/README.md | 27 +++++++ commando/commando.py | 156 ++++++++++++++++++++++++++++++++++++++ commando/test_commando.py | 55 ++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 commando/README.md create mode 100755 commando/commando.py create mode 100755 commando/test_commando.py diff --git a/commando/README.md b/commando/README.md new file mode 100644 index 0000000..1f108d8 --- /dev/null +++ b/commando/README.md @@ -0,0 +1,27 @@ +# Commando plugin + +This plugin allows other nodes to send your node commands, and allows you +to send them to other nodes. The nodes must be authorized, and must be +directly connected. + +Motto: Reckless? Try going commando! + +## Installation + +For general plugin installation instructions see the repos main +[README.md](https://github.com/lightningd/plugins/blob/master/README.md#Installation) + +## Options: + +Each of these can be specified more than once: + +* --commando-reader: a node id which can execute `list` and `get` commands +* --commando-writer: a node id which can execute any commands. + +## Example Usage + +$ l1-cli plugin start commando.py commando_reader=0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518 +$ l2-cli plugin start commando.py +$ l2-cli commando 022d223620a359a47ff7f7ac447c85c46c923da53389221a0054c11c1e3ca31d59 stop + + diff --git a/commando/commando.py b/commando/commando.py new file mode 100755 index 0000000..98e83a0 --- /dev/null +++ b/commando/commando.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Commando is a plugin to allow one node to control another. You use +"commando" to send commands, and the 'commando-writer' and +'commando-reader' to allow nodes to send you commands. + +The formats are: + +type:4C4F - execute this command +type:594B - reply (with more coming) +type:594D - last reply + +Each one is an 8 byte id (to link replies to command), followed by JSON. +""" +from pyln.client import Plugin, RpcError +import json +import textwrap +import random + +plugin = Plugin() + +# "YOLO"! +COMMANDO_CMD = 0x4c4f + +# Replies are split across multiple CONTINUES, then TERM. +COMMANDO_REPLY_CONTINUES = 0x594b +COMMANDO_REPLY_TERM = 0x594d + + +class CommandResponse: + def __init__(self, req): + self.buf = bytes() + self.req = req + + +def split_cmd(cmdstr): + """Interprets JSON and method and params""" + cmd = json.loads(cmdstr) + + return cmd['method'], cmd.get('params') + + +def send_msg(plugin, peer_id, msgtype, idnum, contents): + """Messages are form [8-byte-id][data]""" + msg = (msgtype.to_bytes(2, 'big') + + idnum.to_bytes(8, 'big') + + bytes(contents, encoding='utf8')) + plugin.rpc.call(plugin.msgcmd, {'node_id': peer_id, 'msg': msg.hex()}) + + +def send_result(plugin, peer_id, idnum, res): + # We can only send 64k in a message. + parts = textwrap.wrap(json.dumps(res), 65000) + for p in parts[:-1]: + send_msg(plugin, peer_id, COMMANDO_REPLY_CONTINUES, idnum, p) + + send_msg(plugin, peer_id, COMMANDO_REPLY_TERM, idnum, parts[-1]) + + +def exec_command(plugin, peer_id, idnum, method, params): + """Run an arbitrary command and message back the result""" + try: + res = {'result': plugin.rpc.call(method, params)} + except RpcError as e: + res = {'error': e.error} + + send_result(plugin, peer_id, idnum, res) + + +def exec_read_command(plugin, peer_id, idnum, method, params): + """Run a list or get command and message back the result""" + if method.startswith('list') or method.startswith('get'): + exec_command(plugin, peer_id, idnum, method, params) + else: + send_result(plugin, peer_id, idnum, {'error': "Not permitted"}) + + +@plugin.hook('custommsg') +def on_custommsg(peer_id, payload, plugin, **kwargs): + pbytes = bytes.fromhex(payload) + mtype = int.from_bytes(pbytes[:2], "big") + idnum = int.from_bytes(pbytes[2:10], "big") + data = pbytes[10:] + + if mtype == COMMANDO_CMD: + if peer_id in plugin.writers: + exec_command(plugin, peer_id, idnum, *split_cmd(data)) + elif peer_id in plugin.readers: + exec_read_command(plugin, peer_id, idnum, *split_cmd(data)) + elif mtype == COMMANDO_REPLY_CONTINUES: + if idnum in plugin.reqs: + plugin.reqs[idnum].buf += data + elif mtype == COMMANDO_REPLY_TERM: + if idnum in plugin.reqs: + plugin.reqs[idnum].buf += data + finished = plugin.reqs[idnum] + del plugin.reqs[idnum] + + try: + ret = json.loads(finished.buf.decode()) + except Exception as e: + # Bad response + finished.req.set_exception(e) + return {'result': 'continue'} + + if 'error' in ret: + # Pass through error + finished.req.set_exception(RpcError('commando', {}, + ret['error'])) + else: + # Pass through result + finished.req.set_result(ret['result']) + return {'result': 'continue'} + + +@plugin.async_method("commando") +def commando(plugin, request, peer_id, method, params=None): + """Send a command to node_id, and wait for a response""" + res = {'method': method} + if params: + res['params'] = params + + while True: + idnum = random.randint(0, 2**64) + if idnum not in plugin.reqs: + break + + plugin.reqs[idnum] = CommandResponse(request) + send_msg(plugin, peer_id, COMMANDO_CMD, idnum, json.dumps(res)) + + +@plugin.init() +def init(options, configuration, plugin): + plugin.writers = options['commando_writer'] + plugin.readers = options['commando_reader'] + plugin.reqs = {} + + # dev-sendcustommsg was renamed to sendcustommsg for 0.10.1 + try: + plugin.rpc.help('sendcustommsg') + plugin.msgcmd = 'sendcustommsg' + except RpcError: + plugin.msgcmd = 'dev-sendcustommsg' + + plugin.log("Initialized with readers {}, writers {}" + .format(plugin.readers, plugin.writers)) + + +plugin.add_option('commando_writer', + description="What nodeid can do all commands?", + default=[], + multi=True) +plugin.add_option('commando_reader', + description="What nodeid can do list/get commands?", + default=[], + multi=True) +plugin.run() diff --git a/commando/test_commando.py b/commando/test_commando.py new file mode 100755 index 0000000..28db8d1 --- /dev/null +++ b/commando/test_commando.py @@ -0,0 +1,55 @@ +import os +from pyln.testing.fixtures import * # noqa: F401,F403 +from pyln.client import Millisatoshi, RpcError + +plugin_path = os.path.join(os.path.dirname(__file__), "commando.py") + + +def test_commando(node_factory): + l1, l2 = node_factory.line_graph(2, fundchannel=False) + + l1.rpc.plugin_start(plugin_path, commando_reader=l2.info['id']) + l2.rpc.plugin_start(plugin_path) + + # This works + res = l2.rpc.call(method='commando', + payload={'peer_id': l1.info['id'], + 'method': 'listpeers'}) + assert len(res['peers']) == 1 + assert res['peers'][0]['id'] == l2.info['id'] + + res = l2.rpc.call(method='commando', + payload={'peer_id': l1.info['id'], + 'method': 'listpeers', + 'params': {'id': l2.info['id']}}) + assert len(res['peers']) == 1 + assert res['peers'][0]['id'] == l2.info['id'] + + # This fails + with pytest.raises(RpcError, match='Not permitted'): + l2.rpc.call(method='commando', + payload={'peer_id': l1.info['id'], + 'method': 'withdraw'}) + + # As a writer, anything goes. + l1.rpc.plugin_stop(plugin_path) + l1.rpc.plugin_start(plugin_path, commando_writer=l2.info['id']) + + with pytest.raises(RpcError, match='missing required parameter'): + l2.rpc.call(method='commando', + payload={'peer_id': l1.info['id'], + 'method': 'withdraw'}) + + ret = l2.rpc.call(method='commando', + payload={'peer_id': l1.info['id'], + 'method': 'ping', + 'params': {'id': l2.info['id']}}) + assert 'totlen' in ret + + # Now, this will go over a single message! + ret = l2.rpc.call(method='commando', + payload={'peer_id': l1.info['id'], + 'method': 'getlog', + 'params': {'level': 'io'}}) + + assert len(json.dumps(ret)) > 65535