diff --git a/plugins/commando.c b/plugins/commando.c index fd9c86a0d..1fcff8d8e 100644 --- a/plugins/commando.c +++ b/plugins/commando.c @@ -1,8 +1,10 @@ #include "config.h" #include +#include #include #include #include +#include #include #include #include @@ -175,14 +177,96 @@ static void commando_error(struct commando *incoming, send_response(NULL, NULL, NULL, reply); } -static const char *check_rune(struct commando *incoming, +struct cond_info { + const struct node_id *peer; + const char *buf; + const jsmntok_t *method; + const jsmntok_t *params; + STRMAP(const jsmntok_t *) cached_params; +}; + +static const char *check_condition(const tal_t *ctx, + const struct rune *rune, + const struct rune_altern *alt, + struct cond_info *cinfo) +{ + const jsmntok_t *ptok; + + if (streq(alt->fieldname, "time")) { + return rune_alt_single_int(ctx, alt, time_now().ts.tv_sec); + } else if (streq(alt->fieldname, "id")) { + const char *id = node_id_to_hexstr(tmpctx, cinfo->peer); + return rune_alt_single_str(ctx, alt, id, strlen(id)); + } else if (streq(alt->fieldname, "method")) { + return rune_alt_single_str(ctx, alt, + cinfo->buf + cinfo->method->start, + cinfo->method->end - cinfo->method->start); + } + + /* Rest are params looksup: generate this once! */ + if (cinfo->params) { + /* Note: we require that params be an obj! */ + const jsmntok_t *t; + size_t i; + + json_for_each_obj(i, t, cinfo->params) { + char *pmemname = tal_fmt(tmpctx, + "pname%.*s", + t->end - t->start, + cinfo->buf + t->start); + size_t off = strlen("pname"); + /* Remove punctuation! */ + for (size_t n = off; pmemname[n]; n++) { + if (cispunct(pmemname[n])) + continue; + pmemname[off++] = pmemname[n]; + } + pmemname[off++] = '\0'; + strmap_add(&cinfo->cached_params, pmemname, t+1); + } + cinfo->params = NULL; + } + + ptok = strmap_get(&cinfo->cached_params, alt->fieldname); + if (!ptok) + return rune_alt_single_missing(ctx, alt); + + return rune_alt_single_str(ctx, alt, + cinfo->buf + ptok->start, + ptok->end - ptok->start); +} + +static const char *check_rune(const tal_t *ctx, + struct commando *incoming, + const struct node_id *peer, const char *buf, const jsmntok_t *method, const jsmntok_t *params, - const jsmntok_t *rune) + const jsmntok_t *runetok) { - /* FIXME! */ - return NULL; + struct rune *rune; + struct cond_info cinfo; + const char *err; + + if (!runetok) + return "Missing rune"; + + rune = rune_from_base64n(tmpctx, buf + runetok->start, + runetok->end - runetok->start); + if (!rune) + return "Invalid rune"; + + cinfo.peer = peer; + cinfo.buf = buf; + cinfo.method = method; + cinfo.params = params; + strmap_init(&cinfo.cached_params); + err = rune_test(ctx, master_rune, rune, check_condition, &cinfo); + /* Just in case they manage to make us speak non-JSON, escape! */ + if (err) + err = json_escape(ctx, take(err))->s; + strmap_clear(&cinfo.cached_params); + return err; } static void try_command(struct node_id *peer, @@ -223,7 +307,7 @@ static void try_command(struct node_id *peer, } rune = json_get_member(buf, toks, "rune"); - failmsg = check_rune(incoming, buf, method, params, rune); + failmsg = check_rune(tmpctx, incoming, peer, buf, method, params, rune); if (failmsg) { commando_error(incoming, COMMANDO_ERROR_REMOTE_AUTH, "Not authorized: %s", failmsg); diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 471d3bc5a..1d39f13de 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -2557,17 +2557,18 @@ def test_commando(node_factory, executor): with pytest.raises(concurrent.futures.TimeoutError): fut.result(10) - l1.rpc.commando_rune() - + rune = l1.rpc.commando_rune()['rune'] # This works res = l2.rpc.call(method='commando', payload={'peer_id': l1.info['id'], + 'rune': rune, 'method': 'listpeers'}) assert len(res['peers']) == 1 assert res['peers'][0]['id'] == l2.info['id'] res = l2.rpc.call(method='commando', payload={'peer_id': l1.info['id'], + 'rune': rune, 'method': 'listpeers', 'params': {'id': l2.info['id']}}) assert len(res['peers']) == 1 @@ -2576,16 +2577,19 @@ def test_commando(node_factory, executor): with pytest.raises(RpcError, match='missing required parameter'): l2.rpc.call(method='commando', payload={'peer_id': l1.info['id'], + 'rune': rune, 'method': 'withdraw'}) with pytest.raises(RpcError, match='unknown parameter: foobar'): l2.rpc.call(method='commando', payload={'peer_id': l1.info['id'], 'method': 'invoice', + 'rune': rune, 'params': {'foobar': 1}}) ret = l2.rpc.call(method='commando', payload={'peer_id': l1.info['id'], + 'rune': rune, 'method': 'ping', 'params': {'id': l2.info['id']}}) assert 'totlen' in ret @@ -2593,6 +2597,7 @@ def test_commando(node_factory, executor): # Now, reply will go over a multiple messages! ret = l2.rpc.call(method='commando', payload={'peer_id': l1.info['id'], + 'rune': rune, 'method': 'getlog', 'params': {'level': 'io'}}) @@ -2601,6 +2606,7 @@ def test_commando(node_factory, executor): # Command will go over multiple messages. ret = l2.rpc.call(method='commando', payload={'peer_id': l1.info['id'], + 'rune': rune, 'method': 'invoice', 'params': {'amount_msat': 'any', 'label': 'label', @@ -2613,6 +2619,7 @@ def test_commando(node_factory, executor): with pytest.raises(RpcError, match='No connection to first peer found') as exc_info: l2.rpc.call(method='commando', payload={'peer_id': l1.info['id'], + 'rune': rune, 'method': 'sendpay', 'params': {'route': [{'amount_msat': 1000, 'id': l1.info['id'], @@ -2653,3 +2660,62 @@ def test_commando_rune(node_factory): rune5 = l1.rpc.commando_rune(rune4['rune'], "pnamelevel!|pnamelevel/io") assert rune5['rune'] == 'Dw2tzGCoUojAyT0JUw7fkYJYqExpEpaDRNTkyvWKoJY9MyZpZF4wMjJkMjIzNjIwYTM1OWE0N2ZmNyZtZXRob2Q9bGlzdHBlZXJzJnBuYW1lbGV2ZWwhfHBuYW1lbGV2ZWwvaW8=' assert rune5['unique_id'] == '3' + + # Replace rune3 with a more useful timestamp! + expiry = int(time.time()) + 15 + rune3 = l1.rpc.commando_rune(restrictions="time<{}".format(expiry)) + successes = ((rune1, "listpeers", {}), + (rune2, "listpeers", {}), + (rune2, "getinfo", {}), + (rune2, "getinfo", {}), + (rune3, "getinfo", {}), + (rune4, "listpeers", {}), + (rune5, "listpeers", {'id': l2.info['id']}), + (rune5, "listpeers", {'id': l2.info['id'], 'level': 'broken'})) + failures = ((rune2, "withdraw", {}), + (rune2, "plugin", {'subcommand': 'list'}), + (rune3, "getinfo", {}), + (rune4, "listnodes", {}), + (rune5, "listpeers", {'id': l2.info['id'], 'level': 'io'})) + + for rune, cmd, params in successes: + l2.rpc.call(method='commando', + payload={'peer_id': l1.info['id'], + 'rune': rune['rune'], + 'method': cmd, + 'params': params}) + + while time.time() < expiry: + time.sleep(1) + + for rune, cmd, params in failures: + print("{} {}".format(cmd, params)) + with pytest.raises(RpcError, match='Not authorized:') as exc_info: + l2.rpc.call(method='commando', + payload={'peer_id': l1.info['id'], + 'rune': rune['rune'], + 'method': cmd, + 'params': params}) + assert exc_info.value.error['code'] == 0x4c51 + + # rune5 can only be used by l2: + l3 = node_factory.get_node() + l3.connect(l1) + with pytest.raises(RpcError, match='Not authorized:') as exc_info: + l3.rpc.call(method='commando', + payload={'peer_id': l1.info['id'], + 'rune': rune5['rune'], + 'method': "listpeers", + 'params': {}}) + assert exc_info.value.error['code'] == 0x4c51 + + # Remote doesn't allow array parameters. + l2.rpc.check_request_schemas = False + with pytest.raises(RpcError, match='Params must be object') as exc_info: + l2.rpc.call(method='commando', + payload={'peer_id': l1.info['id'], + 'rune': rune5['rune'], + 'method': "listpeers", + 'params': [l2.info['id'], 'io']}) + assert exc_info.value.error['code'] == 0x4c50 + l2.rpc.check_request_schemas = True