mirror of
https://github.com/aljazceru/plugins.git
synced 2025-12-24 00:24:19 +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 |
|
| [backup][backup] | A simple and reliable backup plugin |
|
||||||
| [boltz-channel-creation][boltz] | A c-lightning plugin for Boltz Channel Creation Swaps |
|
| [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. |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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
|
[java-api]: https://github.com/clightning4j/JRPClightning
|
||||||
[btcli4j]: https://github.com/clightning4j/btcli4j
|
[btcli4j]: https://github.com/clightning4j/btcli4j
|
||||||
[backup]: https://github.com/lightningd/plugins/tree/master/backup
|
[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
|
## 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
|
For general plugin installation instructions see the repos main
|
||||||
[README.md](https://github.com/lightningd/plugins/blob/master/README.md#Installation)
|
[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.
|
* --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
|
For quick testing, you can use this fairly awkward command to start the
|
||||||
$ l2-cli plugin start $(pwd)/commando.py
|
plugin dynamically, with a reader by node id:
|
||||||
$ l2-cli commando 022d223620a359a47ff7f7ac447c85c46c923da53389221a0054c11c1e3ca31d59 stop
|
|
||||||
|
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
|
#!/usr/bin/env python3
|
||||||
"""Commando is a plugin to allow one node to control another. You use
|
"""Commando is a plugin to allow one node to control another. You use
|
||||||
"commando" to send commands, and the 'commando-writer' and
|
"commando" to send commands, with 'method', 'params' and optional
|
||||||
'commando-reader' to allow nodes to send you commands.
|
'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:
|
The formats are:
|
||||||
|
|
||||||
@@ -10,11 +16,17 @@ type:594B - reply (with more coming)
|
|||||||
type:594D - last reply
|
type:594D - last reply
|
||||||
|
|
||||||
Each one is an 8 byte id (to link replies to command), followed by JSON.
|
Each one is an 8 byte id (to link replies to command), followed by JSON.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from pyln.client import Plugin, RpcError
|
from pyln.client import Plugin, RpcError
|
||||||
import json
|
import json
|
||||||
import textwrap
|
import textwrap
|
||||||
|
import time
|
||||||
import random
|
import random
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import runes
|
||||||
|
from typing import Dict, Tuple, Optional
|
||||||
|
|
||||||
plugin = Plugin()
|
plugin = Plugin()
|
||||||
|
|
||||||
@@ -36,7 +48,7 @@ def split_cmd(cmdstr):
|
|||||||
"""Interprets JSON and method and params"""
|
"""Interprets JSON and method and params"""
|
||||||
cmd = json.loads(cmdstr)
|
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):
|
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])
|
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"""
|
"""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:
|
try:
|
||||||
res = {'result': plugin.rpc.call(method, params)}
|
res = {'result': plugin.rpc.call(method, params)}
|
||||||
except RpcError as e:
|
except RpcError as e:
|
||||||
@@ -67,14 +152,6 @@ def exec_command(plugin, peer_id, idnum, method, params):
|
|||||||
send_result(plugin, peer_id, idnum, res)
|
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')
|
@plugin.hook('custommsg')
|
||||||
def on_custommsg(peer_id, payload, plugin, **kwargs):
|
def on_custommsg(peer_id, payload, plugin, **kwargs):
|
||||||
pbytes = bytes.fromhex(payload)
|
pbytes = bytes.fromhex(payload)
|
||||||
@@ -83,10 +160,8 @@ def on_custommsg(peer_id, payload, plugin, **kwargs):
|
|||||||
data = pbytes[10:]
|
data = pbytes[10:]
|
||||||
|
|
||||||
if mtype == COMMANDO_CMD:
|
if mtype == COMMANDO_CMD:
|
||||||
if peer_id in plugin.writers:
|
method, params, runestr = split_cmd(data)
|
||||||
exec_command(plugin, peer_id, idnum, *split_cmd(data))
|
try_command(plugin, peer_id, idnum, method, params, runestr)
|
||||||
elif peer_id in plugin.readers:
|
|
||||||
exec_read_command(plugin, peer_id, idnum, *split_cmd(data))
|
|
||||||
elif mtype == COMMANDO_REPLY_CONTINUES:
|
elif mtype == COMMANDO_REPLY_CONTINUES:
|
||||||
if idnum in plugin.reqs:
|
if idnum in plugin.reqs:
|
||||||
plugin.reqs[idnum].buf += data
|
plugin.reqs[idnum].buf += data
|
||||||
@@ -114,12 +189,15 @@ def on_custommsg(peer_id, payload, plugin, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@plugin.async_method("commando")
|
@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"""
|
"""Send a command to node_id, and wait for a response"""
|
||||||
res = {'method': method}
|
res = {'method': method}
|
||||||
if params:
|
if params:
|
||||||
res['params'] = params
|
res['params'] = params
|
||||||
|
if rune:
|
||||||
|
res['rune'] = rune
|
||||||
|
|
||||||
|
print("Trying command {}".format(res))
|
||||||
while True:
|
while True:
|
||||||
idnum = random.randint(0, 2**64)
|
idnum = random.randint(0, 2**64)
|
||||||
if idnum not in plugin.reqs:
|
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))
|
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()
|
@plugin.init()
|
||||||
def init(options, configuration, plugin):
|
def init(options, configuration, plugin):
|
||||||
|
plugin.reqs = {}
|
||||||
plugin.writers = options['commando_writer']
|
plugin.writers = options['commando_writer']
|
||||||
plugin.readers = options['commando_reader']
|
plugin.readers = options['commando_reader']
|
||||||
plugin.reqs = {}
|
plugin.version = plugin.rpc.getinfo()['version']
|
||||||
|
|
||||||
# dev-sendcustommsg was renamed to sendcustommsg for 0.10.1
|
# dev-sendcustommsg was renamed to sendcustommsg for 0.10.1
|
||||||
try:
|
try:
|
||||||
@@ -142,8 +289,40 @@ def init(options, configuration, plugin):
|
|||||||
except RpcError:
|
except RpcError:
|
||||||
plugin.msgcmd = 'dev-sendcustommsg'
|
plugin.msgcmd = 'dev-sendcustommsg'
|
||||||
|
|
||||||
plugin.log("Initialized with readers {}, writers {}"
|
# Unfortunately, on startup it can take a while for
|
||||||
.format(plugin.readers, plugin.writers))
|
# 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',
|
plugin.add_option('commando_writer',
|
||||||
@@ -151,7 +330,7 @@ plugin.add_option('commando_writer',
|
|||||||
default=[],
|
default=[],
|
||||||
multi=True)
|
multi=True)
|
||||||
plugin.add_option('commando_reader',
|
plugin.add_option('commando_reader',
|
||||||
description="What nodeid can do list/get commands?",
|
description="What nodeid can do list/get/summary commands?",
|
||||||
default=[],
|
default=[],
|
||||||
multi=True)
|
multi=True)
|
||||||
plugin.run()
|
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
|
import os
|
||||||
from pyln.testing.fixtures import * # noqa: F401,F403
|
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")
|
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):
|
def test_commando(node_factory):
|
||||||
@@ -26,7 +43,7 @@ def test_commando(node_factory):
|
|||||||
assert res['peers'][0]['id'] == l2.info['id']
|
assert res['peers'][0]['id'] == l2.info['id']
|
||||||
|
|
||||||
# This fails
|
# 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',
|
l2.rpc.call(method='commando',
|
||||||
payload={'peer_id': l1.info['id'],
|
payload={'peer_id': l1.info['id'],
|
||||||
'method': 'withdraw'})
|
'method': 'withdraw'})
|
||||||
@@ -53,3 +70,216 @@ def test_commando(node_factory):
|
|||||||
'params': {'level': 'io'}})
|
'params': {'level': 'io'}})
|
||||||
|
|
||||||
assert len(json.dumps(ret)) > 65535
|
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