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:
Rusty Russell
2021-08-03 16:54:07 +09:30
parent c05c8bf300
commit ad27d4f624
5 changed files with 572 additions and 34 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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,24 +69,89 @@ 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):
"""Run an arbitrary command and message back the result"""
def is_rune_valid(plugin, runestr) -> Tuple[Optional[runes.Rune], str]:
"""Is this runestring valid, and authorized for us?"""
try:
res = {'result': plugin.rpc.call(method, params)}
except RpcError as e:
res = {'error': e.error}
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:
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)
@@ -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()

View File

@@ -0,0 +1 @@
runes>=0.3.1

View File

@@ -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'])})