mirror of
https://github.com/aljazceru/plugins.git
synced 2025-12-23 16:14:20 +01:00
commando: reckless? Try going commando!
Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
This commit is contained in:
27
commando/README.md
Normal file
27
commando/README.md
Normal 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
156
commando/commando.py
Executable 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
55
commando/test_commando.py
Executable 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
|
||||
Reference in New Issue
Block a user