mirror of
https://github.com/aljazceru/plugins.git
synced 2025-12-23 16:14:20 +01:00
commando: optionally use runes for authorization.
Now it requires the datastore (or datastore.py plugin), if you want to use runes. The simple reader/writer control lists don't require that, but for simplicity we always require the runes Python module. Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
This commit is contained in:
@@ -12,6 +12,7 @@ Community curated plugins for c-lightning.
|
||||
| [backup][backup] | A simple and reliable backup plugin |
|
||||
| [boltz-channel-creation][boltz] | A c-lightning plugin for Boltz Channel Creation Swaps |
|
||||
| [btcli4j][btcli4j] | A Bitcoin Backend to enable safely the pruning mode, and support also rest APIs. |
|
||||
| [commando][commando] | Authorize peers to run commands on your node, and running commands on them. |
|
||||
| [csvexportpays][csvexportpays] | A plugin that exports all payments to a CSV file |
|
||||
| [currencyrate][currencyrate] | A plugin to convert other currencies to BTC using web requests |
|
||||
| [donations][donations] | A simple donations page to accept donations from the web |
|
||||
@@ -206,3 +207,4 @@ Python plugins developers must ensure their plugin to work with all Python versi
|
||||
[java-api]: https://github.com/clightning4j/JRPClightning
|
||||
[btcli4j]: https://github.com/clightning4j/btcli4j
|
||||
[backup]: https://github.com/lightningd/plugins/tree/master/backup
|
||||
[commando]: https://github.com/lightningd/plugins/tree/master/commando
|
||||
|
||||
@@ -8,20 +8,146 @@ 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:
|
||||
## Options and Commands
|
||||
|
||||
Each of these can be specified more than once:
|
||||
There are two configuration options, which can be specified multiple
|
||||
times:
|
||||
|
||||
* --commando-reader: a node id which can execute `list` and `get` commands
|
||||
* --commando-reader: a node id which can execute `list` and `get` / `summary` commands
|
||||
* --commando-writer: a node id which can execute any commands.
|
||||
|
||||
## Example Usage
|
||||
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.
|
||||
|
||||
$ l1-cli plugin subcommand=start plugin=`pwd`/commando.py commando_reader=0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518
|
||||
$ l2-cli plugin start $(pwd)/commando.py
|
||||
$ l2-cli commando 022d223620a359a47ff7f7ac447c85c46c923da53389221a0054c11c1e3ca31d59 stop
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
#!/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.
|
||||
"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:
|
||||
|
||||
@@ -10,11 +16,17 @@ 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 time
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
import runes
|
||||
from typing import Dict, Tuple, Optional
|
||||
|
||||
plugin = Plugin()
|
||||
|
||||
@@ -36,7 +48,7 @@ def split_cmd(cmdstr):
|
||||
"""Interprets JSON and method and params"""
|
||||
cmd = json.loads(cmdstr)
|
||||
|
||||
return cmd['method'], cmd.get('params')
|
||||
return cmd['method'], cmd.get('params', {}), cmd.get('rune')
|
||||
|
||||
|
||||
def send_msg(plugin, peer_id, msgtype, idnum, contents):
|
||||
@@ -57,8 +69,81 @@ def send_result(plugin, peer_id, idnum, res):
|
||||
send_msg(plugin, peer_id, COMMANDO_REPLY_TERM, idnum, parts[-1])
|
||||
|
||||
|
||||
def exec_command(plugin, peer_id, idnum, method, params):
|
||||
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"""
|
||||
print('rune = {}'.format(runestr))
|
||||
# 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_runes(plugin, plugin.peer_runes)
|
||||
return {'result': {'rune': runestr}}
|
||||
|
||||
|
||||
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}
|
||||
else:
|
||||
try:
|
||||
res = {'result': plugin.rpc.call(method, params)}
|
||||
except RpcError as e:
|
||||
@@ -67,14 +152,6 @@ def exec_command(plugin, peer_id, idnum, method, params):
|
||||
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)
|
||||
@@ -83,10 +160,8 @@ def on_custommsg(peer_id, payload, plugin, **kwargs):
|
||||
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))
|
||||
method, params, runestr = split_cmd(data)
|
||||
try_command(plugin, peer_id, idnum, method, params, runestr)
|
||||
elif mtype == COMMANDO_REPLY_CONTINUES:
|
||||
if idnum in plugin.reqs:
|
||||
plugin.reqs[idnum].buf += data
|
||||
@@ -114,12 +189,15 @@ def on_custommsg(peer_id, payload, plugin, **kwargs):
|
||||
|
||||
|
||||
@plugin.async_method("commando")
|
||||
def commando(plugin, request, peer_id, method, params=None):
|
||||
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
|
||||
|
||||
print("Trying command {}".format(res))
|
||||
while True:
|
||||
idnum = random.randint(0, 2**64)
|
||||
if idnum not in plugin.reqs:
|
||||
@@ -129,11 +207,80 @@ def commando(plugin, request, peer_id, method, params=None):
|
||||
send_msg(plugin, peer_id, COMMANDO_CMD, idnum, json.dumps(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'))
|
||||
return rune.to_base64()
|
||||
|
||||
|
||||
def save_peer_runes(plugin, peer_runes) -> None:
|
||||
assert plugin.have_datastore
|
||||
string = '\n'.join(['{}={}'.format(p, r) for p, r in peer_runes.items()])
|
||||
plugin.rpc.datastore(key='commando-peer_runes',
|
||||
string=string,
|
||||
mode='create-or-replace')
|
||||
|
||||
|
||||
def load_peer_runes(plugin) -> Dict[str, str]:
|
||||
if not plugin.have_datastore:
|
||||
return {}
|
||||
|
||||
arr = plugin.rpc.listdatastore(key='commando-peer_runes')['datastore']
|
||||
if arr == []:
|
||||
return {}
|
||||
peer_runes = {}
|
||||
vals = bytes.fromhex(arr[0]['hex']).split(b'\n')
|
||||
for p, r in [v.decode().split('=', maxsplit=1) for v in vals]:
|
||||
peer_runes[p] = r
|
||||
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()
|
||||
else:
|
||||
this_rune, whynot = is_rune_valid(plugin, rune)
|
||||
if this_rune is None:
|
||||
raise RpcError('commando-rune', {'rune': rune}, whynot)
|
||||
|
||||
print("restrictions = {}".format(restrictions))
|
||||
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:
|
||||
print("Trying restriction {}".format(r))
|
||||
this_rune.add_restriction(runes.Restriction.from_str(r))
|
||||
|
||||
return {'rune': this_rune.to_base64()}
|
||||
|
||||
|
||||
@plugin.init()
|
||||
def init(options, configuration, plugin):
|
||||
plugin.reqs = {}
|
||||
plugin.writers = options['commando_writer']
|
||||
plugin.readers = options['commando_reader']
|
||||
plugin.reqs = {}
|
||||
plugin.version = plugin.rpc.getinfo()['version']
|
||||
|
||||
# dev-sendcustommsg was renamed to sendcustommsg for 0.10.1
|
||||
try:
|
||||
@@ -142,8 +289,40 @@ def init(options, configuration, plugin):
|
||||
except RpcError:
|
||||
plugin.msgcmd = 'dev-sendcustommsg'
|
||||
|
||||
plugin.log("Initialized with readers {}, writers {}"
|
||||
.format(plugin.readers, plugin.writers))
|
||||
# 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:
|
||||
print("Trying listdatastore...")
|
||||
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())
|
||||
else:
|
||||
secret = bytes.fromhex(secret[0]['hex'])
|
||||
plugin.log("Initialized with rune support", level="info")
|
||||
|
||||
plugin.masterrune = runes.MasterRune(secret)
|
||||
plugin.peer_runes = load_peer_runes(plugin)
|
||||
|
||||
|
||||
plugin.add_option('commando_writer',
|
||||
@@ -151,7 +330,7 @@ plugin.add_option('commando_writer',
|
||||
default=[],
|
||||
multi=True)
|
||||
plugin.add_option('commando_reader',
|
||||
description="What nodeid can do list/get commands?",
|
||||
description="What nodeid can do list/get/summary commands?",
|
||||
default=[],
|
||||
multi=True)
|
||||
plugin.run()
|
||||
|
||||
1
commando/requirements.txt
Normal file
1
commando/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
runes>=0.3.1
|
||||
@@ -1,8 +1,25 @@
|
||||
import os
|
||||
from pyln.testing.fixtures import * # noqa: F401,F403
|
||||
from pyln.client import Millisatoshi, RpcError
|
||||
from pyln.client import RpcError
|
||||
import pytest
|
||||
import json
|
||||
import runes
|
||||
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):
|
||||
@@ -26,7 +43,7 @@ def test_commando(node_factory):
|
||||
assert res['peers'][0]['id'] == l2.info['id']
|
||||
|
||||
# This fails
|
||||
with pytest.raises(RpcError, match='Not permitted'):
|
||||
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'})
|
||||
@@ -53,3 +70,216 @@ def test_commando(node_factory):
|
||||
'params': {'level': 'io'}})
|
||||
|
||||
assert len(json.dumps(ret)) > 65535
|
||||
|
||||
|
||||
def test_commando_no_datastore(node_factory):
|
||||
l1, l2 = node_factory.line_graph(2, fundchannel=False,
|
||||
opts={'plugin': plugin_path})
|
||||
# These can happen before other init messages
|
||||
l1.daemon.logsearch_start = 0
|
||||
l1.daemon.wait_for_log("Initialized without rune support")
|
||||
l2.daemon.logsearch_start = 0
|
||||
l2.daemon.wait_for_log("Initialized without rune support")
|
||||
|
||||
with pytest.raises(RpcError, match="No datastore available"):
|
||||
l2.rpc.commando_rune(l1.info['id'])
|
||||
|
||||
|
||||
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'])])
|
||||
assert rune2 == rune2a
|
||||
|
||||
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'])})
|
||||
|
||||
Reference in New Issue
Block a user