commando: reckless? Try going commando!

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
This commit is contained in:
Rusty Russell
2021-07-25 17:52:52 +09:30
parent de60a232e3
commit 5d062830ba
3 changed files with 238 additions and 0 deletions

27
commando/README.md Normal file
View File

@@ -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

156
commando/commando.py Executable file
View File

@@ -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()

55
commando/test_commando.py Executable file
View File

@@ -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