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