From ad27d4f624d5c7a80fa57733e23792e709998eb6 Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Tue, 3 Aug 2021 16:54:07 +0930 Subject: [PATCH] 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 --- README.md | 2 + commando/README.md | 140 +++++++++++++++++++++-- commando/commando.py | 229 +++++++++++++++++++++++++++++++++---- commando/requirements.txt | 1 + commando/test_commando.py | 234 +++++++++++++++++++++++++++++++++++++- 5 files changed, 572 insertions(+), 34 deletions(-) create mode 100644 commando/requirements.txt diff --git a/README.md b/README.md index f50d63f..7f0cd69 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/commando/README.md b/commando/README.md index 0e485c0..e871627 100644 --- a/commando/README.md +++ b/commando/README.md @@ -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. diff --git a/commando/commando.py b/commando/commando.py index 8fd7b38..042ae99 100755 --- a/commando/commando.py +++ b/commando/commando.py @@ -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() diff --git a/commando/requirements.txt b/commando/requirements.txt new file mode 100644 index 0000000..24c0f40 --- /dev/null +++ b/commando/requirements.txt @@ -0,0 +1 @@ +runes>=0.3.1 diff --git a/commando/test_commando.py b/commando/test_commando.py index 28db8d1..612b21c 100755 --- a/commando/test_commando.py +++ b/commando/test_commando.py @@ -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'])})