mirror of
https://github.com/aljazceru/plugins.git
synced 2025-12-30 19:34:21 +01:00
Rename directory 'archive' to 'Unmaintained'
This commit is contained in:
174
Unmaintained/commando/README.md
Normal file
174
Unmaintained/commando/README.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Commando plugin
|
||||
|
||||
Commando has been **included in Core Lightning as first class C plugin**.
|
||||
|
||||
It has been actively developed since and has more cool new features added
|
||||
than listed below.
|
||||
|
||||
Checkout latest updates on commando at:
|
||||
https://docs.corelightning.org/docs/commando &
|
||||
https://docs.corelightning.org/reference/lightning-commando
|
||||
|
||||
------------------------------------------------------------------------------------------------------
|
||||
|
||||
# Archived Commando python 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
|
||||
|
||||
This plugin requires the runes library; and to use runes requires
|
||||
datastore support. You can either use a lightningd version after
|
||||
0.10.1, or the [datastore plugin](https://github.com/lightningd/plugins/blob/datastore/README.md).
|
||||
|
||||
For general plugin installation instructions see the repos main
|
||||
[README.md](https://github.com/lightningd/plugins/blob/master/README.md#Installation)
|
||||
|
||||
## Options and Commands
|
||||
|
||||
There are two configuration options, which can be specified multiple
|
||||
times:
|
||||
|
||||
* --commando-reader: a node id which can execute (most) `list` and `get` / `summary` commands
|
||||
* --commando-writer: a node id which can execute any commands.
|
||||
|
||||
You can do this for static access lists, no runes necessary. You would
|
||||
normally put "commando-writer" (or "commando-reader") lines in your
|
||||
config file.
|
||||
|
||||
For quick testing, you can use this fairly awkward command to start the
|
||||
plugin dynamically, with a reader by node id:
|
||||
|
||||
lightning-cli plugin subcommand=start plugin=`pwd`/commando.py commando_reader=0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518
|
||||
|
||||
|
||||
### Using Commando to Control A Node
|
||||
|
||||
Once the node has authorized you can run the `commando` command to send it a
|
||||
command, like this example which sends a `stop` message to 022d...
|
||||
|
||||
lightning-cli commando 022d223620a359a47ff7f7ac447c85c46c923da53389221a0054c11c1e3ca31d59 stop
|
||||
|
||||
For more advanced authorization, you can create **runes** which permit
|
||||
restricted access, and send them along with commands.
|
||||
|
||||
|
||||
### Creating Runes
|
||||
|
||||
If you have datastore support (see the [datastore
|
||||
plugin](https://github.com/lightningd/plugins/blob/datastore/README.md),
|
||||
you can also create a "rune": anyone who has the rune can use it to
|
||||
execute the commands it allows.
|
||||
|
||||
- `commando-rune` by itself gives a "full access" rune.
|
||||
- `commando-rune restrictions=readonly` gives a rune which is restricted to get,
|
||||
list and summary commands.
|
||||
|
||||
For example, say we have peer
|
||||
0336efaa22b8ba77ae721a25d589e1c5f2486073dd2f041add32a23316150e8b62,
|
||||
and we want to allow peer
|
||||
0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518 to
|
||||
run read-only commands:
|
||||
|
||||
0336...$ lightning-cli commando-rune restrictions=readonly
|
||||
{
|
||||
"rune": "ZN7IkVe8S0fO7htvQ23mCMQ-QGzFTvn0OZPqucp881Vjb21tYW5kXmxpc3R8Y29tbWFuZF5nZXR8Y29tbWFuZD1zdW1tYXJ5JmNvbW1hbmQvZ2V0c2hhcmVkc2VjcmV0"
|
||||
}
|
||||
|
||||
We could hand that rune out to anyone, to access.
|
||||
|
||||
### Using Runes
|
||||
|
||||
You use a rune a peer gives you with the `rune` parameter to `commando`, eg:
|
||||
|
||||
02be...$ lightning-cli commando 0336efaa22b8ba77ae721a25d589e1c5f2486073dd2f041add32a23316150e8b62 listchannels {} j2fEW43Y8Ie7d0oGt9pPxaIcl6RP6MjRGC1mgxKuUDxpZD0wMmJlODVlNzA4MjFlNmNjZjIxNDlmMWE3YmY1ZTM0ZDc3OTAwMGY3MjgxNTQ1MDhjYzkwNzJlNGU5MDE4MmNkZDI=
|
||||
|
||||
Or, using keyword parameters:
|
||||
|
||||
02be...$ lightning-cli commando peer_id=0336efaa22b8ba77ae721a25d589e1c5f2486073dd2f041add32a23316150e8b62 method=listchannels rune=j2fEW43Y8Ie7d0oGt9pPxaIcl6RP6MjRGC1mgxKuUDxpZD0wMmJlODVlNzA4MjFlNmNjZjIxNDlmMWE3YmY1ZTM0ZDc3OTAwMGY3MjgxNTQ1MDhjYzkwNzJlNGU5MDE4MmNkZDI=
|
||||
|
||||
It's more common to set your peer to persistently cache the rune as the default for whenever you issue a command, using `commando-cacherune`:
|
||||
|
||||
02be...$ lightning-cli commando 0336efaa22b8ba77ae721a25d589e1c5f2486073dd2f041add32a23316150e8b62 commando-cacherune {} j2fEW43Y8Ie7d0oGt9pPxaIcl6RP6MjRGC1mgxKuUDxpZD0wMmJlODVlNzA4MjFlNmNjZjIxNDlmMWE3YmY1ZTM0ZDc3OTAwMGY3MjgxNTQ1MDhjYzkwNzJlNGU5MDE4MmNkZDI=
|
||||
02be...$ lightning-cli commando 0336efaa22b8ba77ae721a25d589e1c5f2486073dd2f041add32a23316150e8b62 listpeers
|
||||
|
||||
|
||||
### Restricting Runes
|
||||
|
||||
There's a [runes library](https://github.com/rustyrussell/runes/): which lets you add restrictions, but for
|
||||
convenience the `commando-rune` can also add them, like so:
|
||||
|
||||
- `commandorune RUNE RESTRICTION...`
|
||||
|
||||
Each RESTRICTION is a string: fieldname, followed by a condition, followed by a
|
||||
value. It can either be a single string, or an array of strings.
|
||||
|
||||
Valid fieldnames are:
|
||||
* **id**: what peer is allowed to use it.
|
||||
* **time**: time in seconds since 1970, as returned by `date +%s` or Python `int(time.time())`.
|
||||
* **version**: what version of c-lightning is running.
|
||||
* **command**: the command they are trying to run (e.g. "listpeers")
|
||||
* **parr0**..**parrN**: the parameters if specified using a JSON array
|
||||
* **pnameNAME**..: the parameters (by name) if specified using a JSON object ('-' and other punctuation are removed from NAME).
|
||||
|
||||
conditions are listed in the [runes documentation](https://github.com/rustyrussell/runes/blob/v0.3.1/README.md#rune-language):
|
||||
|
||||
* `!`: Pass if field is missing (value ignored)
|
||||
* `=`: Pass if exists and exactly equals
|
||||
* `^`: Pass if exists and begins with
|
||||
* `$`: Pass if exists and ends with
|
||||
* `~`: Pass if exists and contains
|
||||
* `<`: Pass if exists, is a valid decimal (may be signed), and numerically less than
|
||||
* `>`: Pass if exists, is a valid decimal (may be signed), and numerically greater than
|
||||
* `}`: Pass if exists and lexicograpically greater than (or longer)
|
||||
* `{`: Pass if exists and lexicograpically less than (or shorter)
|
||||
* `#`: Always pass: no condition, this is a comment.
|
||||
|
||||
Say we have peer
|
||||
0336efaa22b8ba77ae721a25d589e1c5f2486073dd2f041add32a23316150e8b62,
|
||||
and we want to allow peer
|
||||
0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518 to
|
||||
run listpeers on itself. This is actually three restrictions:
|
||||
|
||||
1. id=0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518,
|
||||
since it must be the one initiating the command.
|
||||
2. method=listpeers, since that's the only command it can run.
|
||||
3. pnameid=0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518 OR
|
||||
parr0=0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518;
|
||||
we let them specify parameters by name or array, so allow both.
|
||||
|
||||
We can add these restrictions one at a time, or specify them all at
|
||||
once. By default, we start with the master rune, which has no
|
||||
restrictions:
|
||||
|
||||
0336...$ lightning-cli commando-rune restrictions='["id=02be85e70821e6ccf2149f1a7bf5e34d779000f728154508cc9072e4e90182cdd2","method=listpeers","pnameid=0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518|parr0=0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518"]'
|
||||
{
|
||||
"rune": "-As0gqymadZpgTnm9fBoDVtrjPmpwPrKmCQWUcqlouJpZD0wMmJlODVlNzA4MjFlNmNjZjIxNDlmMWE3YmY1ZTM0ZDc3OTAwMGY3MjgxNTQ1MDhjYzkwNzJlNGU5MDE4MmNkZDImbWV0aG9kPWxpc3RwZWVycyZwbmFtZWlkPTAyNjZlNDU5OGQxZDNjNDE1ZjU3MmE4NDg4ODMwYjYwZjdlNzQ0ZWQ5MjM1ZWIwYjFiYTkzMjgzYjMxNWMwMzUxOHxwYXJyMD0wMjY2ZTQ1OThkMWQzYzQxNWY1NzJhODQ4ODgzMGI2MGY3ZTc0NGVkOTIzNWViMGIxYmE5MzI4M2IzMTVjMDM1MTg="
|
||||
}
|
||||
|
||||
We can publish this on Twitter and it doesn't matter, since it only
|
||||
works for that one peer.
|
||||
|
||||
|
||||
### Temporary Runes to Authorize Yourself
|
||||
|
||||
This creates a rune which can only be used to create another rune for
|
||||
a specific nodeid, for (as of this writing!) the next 60 seconds:
|
||||
|
||||
lightning-cli commando-rune restrictions='["method=commando-rune","pnamerestrictions^id=|parr1^id=","time<1627886935"]'
|
||||
|
||||
That rune only allows them to rune "commando-rune" with an "id="
|
||||
restriction, within the given time; useful to place in a QR code to
|
||||
allow self-authorization.
|
||||
|
||||
|
||||
### Huge Commands and Responses
|
||||
|
||||
Commands larger than about 64k are split into multiple parts; command
|
||||
responses similarly. To avoid a Denial of Service, commands must be
|
||||
less than about 1MB in size: that's increased to 10MB if the peer has
|
||||
successfully used `commando-cacherune`.
|
||||
426
Unmaintained/commando/commando.py
Executable file
426
Unmaintained/commando/commando.py
Executable file
@@ -0,0 +1,426 @@
|
||||
#!/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()
|
||||
2
Unmaintained/commando/requirements.txt
Normal file
2
Unmaintained/commando/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
runes>=0.4
|
||||
pyln-client>=0.10.1
|
||||
325
Unmaintained/commando/test_commando.py
Executable file
325
Unmaintained/commando/test_commando.py
Executable file
@@ -0,0 +1,325 @@
|
||||
import os
|
||||
from pyln.testing.fixtures import * # type: ignore
|
||||
from pyln.client import RpcError # type: ignore
|
||||
import pytest
|
||||
import json
|
||||
import runes # type: ignore
|
||||
import commando
|
||||
import time
|
||||
|
||||
plugin_path = os.path.join(os.path.dirname(__file__), "commando.py")
|
||||
datastore_path = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
||||
"datastore", "datastore.py")
|
||||
|
||||
|
||||
def test_add_reader_restrictions():
|
||||
mrune = runes.MasterRune(bytes(32))
|
||||
runestr = commando.add_reader_restrictions(mrune.copy())
|
||||
assert mrune.check_with_reason(runestr, {'method': 'listfoo'}) == (True, '')
|
||||
assert mrune.check_with_reason(runestr, {'method': 'getfoo'}) == (True, '')
|
||||
assert mrune.check_with_reason(runestr, {'method': 'getsharedsecret'}) == (False, 'method: = getsharedsecret')
|
||||
assert mrune.check_with_reason(runestr, {'method': 'summary'}) == (True, '')
|
||||
assert mrune.check_with_reason(runestr, {'method': 'fail'}) == (False, 'method: does not start with list AND method: does not start with get AND method: != summary')
|
||||
|
||||
|
||||
def test_commando(node_factory):
|
||||
l1, l2 = node_factory.line_graph(2, fundchannel=True)
|
||||
|
||||
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='method: does not start with list AND method: does not start with get AND method: != summary'):
|
||||
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
|
||||
|
||||
|
||||
def test_commando_rune(node_factory):
|
||||
l1, l2, l3 = node_factory.line_graph(3, fundchannel=False,
|
||||
opts={'plugin': [plugin_path,
|
||||
datastore_path]})
|
||||
|
||||
l1.daemon.logsearch_start = 0
|
||||
l1.daemon.wait_for_log("Initialized with rune support")
|
||||
l2.daemon.logsearch_start = 0
|
||||
l2.daemon.wait_for_log("Initialized with rune support")
|
||||
l3.daemon.logsearch_start = 0
|
||||
l3.daemon.wait_for_log("Initialized with rune support")
|
||||
|
||||
wrune = l2.rpc.commando_rune()['rune']
|
||||
rrune = l2.rpc.commando_rune(restrictions='readonly')['rune']
|
||||
|
||||
# This works
|
||||
res = l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'rune': rrune,
|
||||
'method': 'listpeers'})
|
||||
assert len(res['peers']) == 2
|
||||
|
||||
# This fails (no rune!)
|
||||
with pytest.raises(RpcError, match='Not authorized'):
|
||||
l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'method': 'withdraw'})
|
||||
|
||||
# This fails (ro rune!)
|
||||
with pytest.raises(RpcError, match='Not authorized'):
|
||||
res = l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'rune': rrune,
|
||||
'method': 'withdraw'})
|
||||
|
||||
# This would succeed, except missing param)
|
||||
with pytest.raises(RpcError, match='missing required parameter'):
|
||||
res = l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'rune': wrune,
|
||||
'method': 'withdraw'})
|
||||
|
||||
# We can subrune and use that rune explicitly.
|
||||
lcrune = l2.rpc.commando_rune(rrune, 'method=listchannels')['rune']
|
||||
with pytest.raises(RpcError, match='Not authorized'):
|
||||
l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'rune': lcrune,
|
||||
'method': 'listpeers'})
|
||||
|
||||
l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'rune': lcrune,
|
||||
'method': 'listchannels'})
|
||||
|
||||
# Only allow it to list l3's channels (by source, second param)
|
||||
lcrune = l2.rpc.commando_rune(rrune, ['method=listchannels',
|
||||
'pnamesource=' + l3.info['id']
|
||||
+ '|' + 'parr1=' + l3.info['id']])['rune']
|
||||
|
||||
# Needs rune!
|
||||
with pytest.raises(RpcError, match='Not authorized'):
|
||||
l3.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'method': 'listchannels',
|
||||
'params': [None, l3.info['id']]})
|
||||
# Command wrong
|
||||
with pytest.raises(RpcError, match='Not authorized.*method'):
|
||||
l3.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'rune': lcrune,
|
||||
'method': 'withdraw'})
|
||||
|
||||
# Params missing
|
||||
with pytest.raises(RpcError, match='Not authorized.*missing'):
|
||||
l3.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'rune': lcrune,
|
||||
'method': 'listchannels'})
|
||||
|
||||
# Param wrong (array)
|
||||
with pytest.raises(RpcError, match='Not authorized.*parr1'):
|
||||
l3.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'rune': lcrune,
|
||||
'method': 'listchannels',
|
||||
'params': [None, l2.info['id']]})
|
||||
|
||||
# Param wrong (obj)
|
||||
with pytest.raises(RpcError, match='Not authorized.*pnamesource'):
|
||||
l3.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'rune': lcrune,
|
||||
'method': 'listchannels',
|
||||
'params': {'source': l2.info['id']}})
|
||||
|
||||
# Param right (array)
|
||||
l3.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'rune': lcrune,
|
||||
'method': 'listchannels',
|
||||
'params': [None, l3.info['id']]})
|
||||
|
||||
# Param right (obj)
|
||||
l3.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'rune': lcrune,
|
||||
'method': 'listchannels',
|
||||
'params': {'source': l3.info['id']}})
|
||||
|
||||
|
||||
def test_commando_cacherune(node_factory):
|
||||
l1, l2 = node_factory.line_graph(2, fundchannel=False,
|
||||
opts={'plugin': [plugin_path,
|
||||
datastore_path]})
|
||||
restrictions = ['method=listchannels',
|
||||
'pnamesource={id}|parr1={id}'.format(id=l1.info['id'])]
|
||||
lcrune = l2.rpc.commando_rune(restrictions=restrictions)['rune']
|
||||
|
||||
# You can't set it, it needs to be via commando!
|
||||
with pytest.raises(RpcError,
|
||||
match='Must be called as a remote commando call'):
|
||||
l1.rpc.commando_cacherune(lcrune)
|
||||
|
||||
l1.rpc.commando(peer_id=l2.info['id'],
|
||||
method='commando-cacherune',
|
||||
rune=lcrune)
|
||||
|
||||
# Param wrong (array)
|
||||
with pytest.raises(RpcError, match='Not authorized.*parr1'):
|
||||
l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'method': 'listchannels',
|
||||
'params': [None, l2.info['id']]})
|
||||
|
||||
# Param wrong (obj)
|
||||
with pytest.raises(RpcError, match='Not authorized.*pnamesource'):
|
||||
l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'method': 'listchannels',
|
||||
'params': {'source': l2.info['id']}})
|
||||
|
||||
# Param right (array)
|
||||
l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'method': 'listchannels',
|
||||
'params': [None, l1.info['id']]})
|
||||
|
||||
# Param right (obj)
|
||||
l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'method': 'listchannels',
|
||||
'params': {'source': l1.info['id']}})
|
||||
|
||||
# Still works after restart!
|
||||
l2.restart()
|
||||
l1.rpc.connect(l2.info['id'], 'localhost', l2.port)
|
||||
l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'method': 'listchannels',
|
||||
'params': {'source': l1.info['id']}})
|
||||
|
||||
|
||||
def test_rune_time(node_factory):
|
||||
l1, l2 = node_factory.line_graph(2, fundchannel=False,
|
||||
opts={'plugin': [plugin_path,
|
||||
datastore_path]})
|
||||
|
||||
rune = l1.rpc.commando_rune(restrictions=["method=commando-rune",
|
||||
"pnamerestrictions^id=|parr1^id=",
|
||||
"time<{}"
|
||||
.format(int(time.time()) + 15)])['rune']
|
||||
# l2 has to obey restrictions
|
||||
with pytest.raises(RpcError, match='Not authorized.*pnamerestrictions'):
|
||||
l2.rpc.commando(peer_id=l1.info['id'], method='commando-rune', rune=rune)
|
||||
|
||||
with pytest.raises(RpcError, match='Not authorized.*pnamerestrictions'):
|
||||
l2.rpc.commando(peer_id=l1.info['id'], method='commando-rune', rune=rune,
|
||||
params={'restrictions': 'id<{}'.format(l2.info['id'])})
|
||||
|
||||
# By name
|
||||
rune2 = l2.rpc.commando(peer_id=l1.info['id'],
|
||||
method='commando-rune',
|
||||
rune=rune,
|
||||
params={'restrictions': 'id={}'.format(l2.info['id'])})
|
||||
# By position
|
||||
rune2a = l2.rpc.commando(peer_id=l1.info['id'],
|
||||
method='commando-rune',
|
||||
rune=rune,
|
||||
params=[None, 'id={}'.format(l2.info['id'])])
|
||||
# r2a ID will be 1 greater than r2 ID
|
||||
r2 = runes.Rune.from_base64(rune2['rune'])
|
||||
r2a = runes.Rune.from_base64(rune2a['rune'])
|
||||
assert len(r2.restrictions) == len(r2a.restrictions)
|
||||
assert r2a.restrictions[0].alternatives == [runes.Alternative(r2.restrictions[0].alternatives[0].field,
|
||||
r2.restrictions[0].alternatives[0].cond,
|
||||
str(int(r2.restrictions[0].alternatives[0].value) + 1))]
|
||||
for r2_r, r2a_r in zip(r2.restrictions[1:], r2a.restrictions[1:]):
|
||||
assert r2_r == r2a_r
|
||||
|
||||
time.sleep(16)
|
||||
with pytest.raises(RpcError, match='Not authorized.*time'):
|
||||
l2.rpc.commando(peer_id=l1.info['id'],
|
||||
method='commando-rune',
|
||||
rune=rune,
|
||||
params={'restrictions': 'id={}'.format(l2.info['id'])})
|
||||
|
||||
|
||||
def test_readonly(node_factory):
|
||||
l1, l2 = node_factory.line_graph(2, fundchannel=False,
|
||||
opts={'plugin': [plugin_path,
|
||||
datastore_path]})
|
||||
rrune = l2.rpc.commando_rune(restrictions='readonly')['rune']
|
||||
|
||||
l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'method': 'listchannels',
|
||||
'rune': rrune,
|
||||
'params': {'source': l1.info['id']}})
|
||||
|
||||
with pytest.raises(RpcError, match='Not authorized.* = getsharedsecret'):
|
||||
l1.rpc.commando(peer_id=l2.info['id'],
|
||||
rune=rrune,
|
||||
method='getsharedsecret')
|
||||
|
||||
with pytest.raises(RpcError, match='Not authorized.* = listdatastore'):
|
||||
l1.rpc.commando(peer_id=l2.info['id'],
|
||||
rune=rrune,
|
||||
method='listdatastore')
|
||||
|
||||
|
||||
def test_megacmd(node_factory):
|
||||
l1, l2 = node_factory.line_graph(2, fundchannel=False,
|
||||
opts={'plugin': [plugin_path,
|
||||
datastore_path]})
|
||||
rrune = l2.rpc.commando_rune(restrictions='readonly')['rune']
|
||||
|
||||
# Proof that it got the rune: fails with "Unknown command" not "Not authorized"
|
||||
with pytest.raises(RpcError, match='Unknown command'):
|
||||
l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'method': 'get' + 'x' * 130000,
|
||||
'rune': rrune,
|
||||
'params': {}})
|
||||
|
||||
with pytest.raises(RpcError, match='Command too long'):
|
||||
l1.rpc.call(method='commando',
|
||||
payload={'peer_id': l2.info['id'],
|
||||
'method': 'get' + 'x' * 1100000,
|
||||
'rune': rrune,
|
||||
'params': {}})
|
||||
Reference in New Issue
Block a user