Files
plugins/archived/commando/commando.py
fmhoeger 92186296e2 Rename directory 'Unmaintained' back to 'archived'
Update text and links for archived plugins in README
2024-02-06 20:29:25 +00:00

427 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""Commando is a plugin to allow one node to control another. You use
"commando" to send commands, with 'method', 'params' and optional
'rune' which authorizes it.
Additionally, you can use "commando-rune" to create/add restrictions to
existing runes (you can also use the runes.py library).
Rather than handing a rune every time, peers can do "commando-cacherune"
to make it the persistent default for their peer_id.
The formats are:
type:4C4D - execute this command (with more coming)
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 # type: ignore
import json
import textwrap
import time
import random
import secrets
import string
import runes # type: ignore
import multiprocessing
from typing import Dict, Tuple, Optional
plugin = Plugin()
# "YOLO"!
COMMANDO_CMD_CONTINUES = 0x4c4d
COMMANDO_CMD_TERM = 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
class InReq:
def __init__(self, idnum):
self.idnum = idnum
self.buf = b''
self.discard = False
def append(self, data):
if not self.discard:
self.buf += data
def start_discard(self):
self.buf = b''
self.discard = True
def split_cmd(cmdstr):
"""Interprets JSON and method and params"""
cmd = json.loads(cmdstr)
return cmd['method'], cmd.get('params', {}), cmd.get('rune')
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_msgs(plugin, peer_id, idnum, obj, msgtype_cont, msgtype_term):
# We can only send 64k in a message, but there is 10 byte overhead
# in the message header; 65000 is safe.
parts = textwrap.wrap(json.dumps(obj), 65000)
for p in parts[:-1]:
send_msg(plugin, peer_id, msgtype_cont, idnum, p)
send_msg(plugin, peer_id, msgtype_term, idnum, parts[-1])
def send_result(plugin, peer_id, idnum, res):
send_msgs(plugin, peer_id, idnum, res,
COMMANDO_REPLY_CONTINUES, COMMANDO_REPLY_TERM)
def send_request(plugin, peer_id, idnum, req):
send_msgs(plugin, peer_id, idnum, req,
COMMANDO_CMD_CONTINUES, COMMANDO_CMD_TERM)
def is_rune_valid(plugin, runestr) -> Tuple[Optional[runes.Rune], str]:
"""Is this runestring valid, and authorized for us?"""
try:
rune = runes.Rune.from_base64(runestr)
except: # noqa: E722
return None, 'Malformed base64 string'
if not plugin.masterrune.is_rune_authorized(rune):
return None, 'Invalid rune string'
return rune, ''
def check_rune(plugin, node_id, runestr, command, params) -> Tuple[bool, str]:
"""If we have a runestr, check it's valid and conditions met"""
# If they don't specify a rune, we use any previous for this peer
if runestr is None:
runestr = plugin.peer_runes.get(node_id)
if runestr is None:
# Finally, try reader-writer lists
if node_id in plugin.writers:
runestr = plugin.masterrune.to_base64()
elif node_id in plugin.readers:
runestr = add_reader_restrictions(plugin.masterrune.copy())
if runestr is None:
return False, 'No rune'
commando_dict = {'time': int(time.time()),
'id': node_id,
'version': plugin.version,
'method': command}
# FIXME: This doesn't work well with complex params (it makes them str())
if isinstance(params, list):
for i, p in enumerate(params):
commando_dict['parr{}'.format(i)] = p
else:
for k, v in params.items():
# Cannot have punctuation in fieldnames, so remove.
for c in string.punctuation:
k = k.replace(c, '')
commando_dict['pname{}'.format(k)] = v
return plugin.masterrune.check_with_reason(runestr, commando_dict)
def do_cacherune(plugin, peer_id, runestr):
if not plugin.have_datastore:
return {'error': 'No datastore available: try datastore.py?'}
if runestr is None:
return {'error': 'No rune set?'}
rune, whynot = is_rune_valid(plugin, runestr)
if not rune:
return {'error': whynot}
plugin.peer_runes[peer_id] = runestr
save_peer_rune(plugin, peer_id, runestr)
return {'result': {'rune': runestr}}
def command_run(plugin, peer_id, idnum, method, params):
"""Function to run a command and write 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 try_command(plugin, peer_id, idnum, method, params, runestr):
"""Run an arbitrary command and message back the result"""
# You can always set your rune, even if *that rune* wouldn't
# allow it!
if method == 'commando-cacherune':
res = do_cacherune(plugin, peer_id, runestr)
else:
ok, failstr = check_rune(plugin, peer_id, runestr, method, params)
if not ok:
res = {'error': 'Not authorized: ' + failstr}
elif method in plugin.methods:
# Don't try to call indirectly into ourselves; we deadlock!
# But commando-rune is useful, so hardcode that.
if method == "commando-rune":
if isinstance(params, list):
res = {'result': commando_rune(plugin, *params)}
else:
res = {'result': commando_rune(plugin, **params)}
else:
res = {'error': 'FIXME: Refusing to call inside ourselves'}
else:
# The subprocess does send_result itself: pyln-client doesn't
# support async RPC yet.
multiprocessing.Process(target=command_run,
args=(plugin, peer_id, idnum, method, params)).start()
return
send_result(plugin, peer_id, idnum, res)
@plugin.async_hook('custommsg')
def on_custommsg(peer_id, payload, plugin, request, **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_CONTINUES:
if peer_id not in plugin.in_reqs or idnum != plugin.in_reqs[peer_id].idnum:
plugin.in_reqs[peer_id] = InReq(idnum)
plugin.in_reqs[peer_id].append(data)
# If you have cached a rune, give 10MB, otherwise 1MB.
# We can have hundreds of these things...
max_cmdlen = 1000000
if peer_id in plugin.peer_runes:
max_cmdlen *= 10
if len(plugin.in_reqs[peer_id].buf) > max_cmdlen:
plugin.in_reqs[peer_id].start_discard()
elif mtype == COMMANDO_CMD_TERM:
# Prepend any prior data from COMMANDO_CMD_CONTINUES:
if peer_id in plugin.in_reqs:
data = plugin.in_reqs[peer_id].buf + data
discard = plugin.in_reqs[peer_id].discard
del plugin.in_reqs[peer_id]
# Were we ignoring this for being too long? Error out now.
if discard:
send_result(plugin, peer_id, idnum,
{'error': "Command too long"})
request.set_result({'result': 'continue'})
return
method, params, runestr = split_cmd(data)
try_command(plugin, peer_id, idnum, method, params, runestr)
elif mtype == COMMANDO_REPLY_CONTINUES:
if idnum in plugin.out_reqs:
plugin.out_reqs[idnum].buf += data
elif mtype == COMMANDO_REPLY_TERM:
if idnum in plugin.out_reqs:
plugin.out_reqs[idnum].buf += data
finished = plugin.out_reqs[idnum]
del plugin.out_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'])
request.set_result({'result': 'continue'})
@plugin.subscribe('disconnect')
def on_disconnect(id, plugin, request, **kwargs):
if id in plugin.in_reqs:
del plugin.in_reqs[id]
@plugin.async_method("commando")
def commando(plugin, request, peer_id, method, params=None, rune=None):
"""Send a command to node_id, and wait for a response"""
res = {'method': method}
if params:
res['params'] = params
if rune:
res['rune'] = rune
while True:
idnum = random.randint(0, 2**64)
if idnum not in plugin.out_reqs:
break
plugin.out_reqs[idnum] = CommandResponse(request)
send_request(plugin, peer_id, idnum, res)
@plugin.method("commando-cacherune")
def commando_cacherune(plugin, rune):
"""Sets the rune given to the persistent rune for this peer_id"""
# This is intercepted by commando runner, above.
raise RpcError('commando-cacherune', {},
'Must be called as a remote commando call')
def add_reader_restrictions(rune: runes.Rune) -> str:
"""Let them execute list or get, but not getsharesecret!"""
# Allow list*, get* or summary.
rune.add_restriction(runes.Restriction.from_str('method^list'
'|method^get'
'|method=summary'))
# But not getsharesecret!
rune.add_restriction(runes.Restriction.from_str('method/getsharedsecret'))
# And not listdatastore!
rune.add_restriction(runes.Restriction.from_str('method/listdatastore'))
return rune.to_base64()
def save_peer_rune(plugin, peer_id, runestr) -> None:
assert plugin.have_datastore
plugin.rpc.datastore(key=['commando', 'peer_runes', peer_id],
string=runestr,
mode='create-or-replace')
def load_peer_runes(plugin) -> Dict[str, str]:
if not plugin.have_datastore:
return {}
peer_runes = {}
entries = plugin.rpc.listdatastore(key=['commando', 'peer_runes'])
for entry in entries['datastore']:
peer_runes[entry['key'][2]] = entry['string']
return peer_runes
@plugin.method("commando-rune")
def commando_rune(plugin, rune=None, restrictions=[]):
"""Create a rune, (or derive from {rune}) with the given
{restrictions} array (or string), or 'readonly'"""
if not plugin.have_datastore:
raise RpcError('commando-rune', {},
'No datastore available: try datastore.py?')
if rune is None:
this_rune = plugin.masterrune.copy()
this_rune.add_restriction(runes.Restriction.unique_id(plugin.rune_counter))
else:
this_rune, whynot = is_rune_valid(plugin, rune)
if this_rune is None:
raise RpcError('commando-rune', {'rune': rune}, whynot)
if restrictions == 'readonly':
add_reader_restrictions(this_rune)
elif isinstance(restrictions, str):
this_rune.add_restriction(runes.Restriction.from_str(restrictions))
else:
for r in restrictions:
this_rune.add_restriction(runes.Restriction.from_str(r))
# Now we've succeeded, update rune_counter.
if rune is None:
plugin.rpc.datastore(key=['commando', 'rune_counter'],
string=str(plugin.rune_counter + 1),
mode='must-replace',
generation=plugin.rune_counter_generation)
plugin.rune_counter += 1
plugin.rune_counter_generation += 1
return {'rune': this_rune.to_base64()}
@plugin.init()
def init(options, configuration, plugin):
plugin.out_reqs = {}
plugin.in_reqs = {}
plugin.writers = options['commando-writer']
plugin.readers = options['commando-reader']
plugin.version = plugin.rpc.getinfo()['version']
# dev-sendcustommsg was renamed to sendcustommsg for 0.10.1
try:
plugin.rpc.help('sendcustommsg')
plugin.msgcmd = 'sendcustommsg'
except RpcError:
plugin.msgcmd = 'dev-sendcustommsg'
# Unfortunately, on startup it can take a while for
# the datastore to be loaded (as it's actually a second plugin,
# loaded by the first.
end = time.time() + 10
secret = None
while time.time() < end:
try:
secret = plugin.rpc.listdatastore(['commando', 'secret'])['datastore']
except RpcError:
time.sleep(1)
else:
break
if secret is None:
# Use a throwaway secret
secret = secrets.token_bytes()
plugin.have_datastore = False
plugin.peer_runes = {}
plugin.log("Initialized without rune support"
" (needs datastore.py plugin)",
level="info")
else:
plugin.have_datastore = True
if secret == []:
plugin.log("Creating initial rune secret", level='unusual')
secret = secrets.token_bytes()
plugin.rpc.datastore(key=['commando', 'secret'], hex=secret.hex())
plugin.rune_counter = 0
plugin.rune_counter_generation = 0
plugin.rpc.datastore(key=['commando', 'rune_counter'], string=str(0))
else:
secret = bytes.fromhex(secret[0]['hex'])
counter = plugin.rpc.listdatastore(['commando', 'rune_counter'])['datastore'][0]
plugin.rune_counter = int(counter['string'])
plugin.rune_counter_generation = int(counter['generation'])
plugin.log("Initialized with rune support: {} runes so far".format(plugin.rune_counter),
level="info")
plugin.masterrune = runes.MasterRune(secret)
plugin.peer_runes = load_peer_runes(plugin)
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/summary commands?",
default=[],
multi=True)
plugin.run()