askrene: implement 10-second deadline.
What changed, and why it matters
This commit adds a 10-second safety cutoff to the routing plugin (askrene) that calculates payment paths in the Core Lightning node. Previously, the routing algorithm could keep looping or running for a long time while trying to find a way to send a payment. The change makes it stop and report failure if it exceeds the configured time limit, with a default of 10 seconds. It also exposes a new node option, askrene-timeout, so operators can adjust or disable it. This is a defensive hardening change rather than a fix for a specific remote exploit.
Treat as a hardening improvement. Operators running affected versions should ensure askrene-timeout is set appropriately for their hardware; the default 10 seconds is reasonable for production but may need raising on very slow or heavily loaded nodes. Monitor logs for 'timed out after deadline' to identify pathological routing inputs that may still need separate fixes.
Security signals we found
Denial-of-service mitigation: prevents unbounded CPU consumption during route computation
Adds configurable deadline to terminate long-running routing algorithm
Commit message references 'another report of looping' in askrene/maxparts code
Defensive hardening while larger rewrite is in progress
Evidence from the diff
The patch introduces a deadline timer into askrene’s MCF-based route computation. A struct timemono deadline is computed from time_mono() plus askrene->route_seconds (default 10) and passed through default_routes(), single_path_routes(), and linear_routes(). linear_routes() now checks timemono_after(time_mono(), deadline) inside its main loop and returns an error (‘linear_routes: timed out after deadline’) if exceeded. A new dynamic plugin option askrene-timeout is registered in main() and documented. Tests confirm the timeout triggers and is configurable. The commit message notes this is a catchall for another reported looping case while maxparts code is being rewritten.
Changed components
plugins/askrene/askrene.cplugins/askrene/askrene.hplugins/askrene/mcf.cplugins/askrene/mcf.hdoc/lightningd-config.5.mdtests/test_askrene.pyInspect captured patch +75 / −8
diff --git a/doc/lightningd-config.5.md b/doc/lightningd-config.5.md
index 631dd35f..73d827c0 100644
--- a/doc/lightningd-config.5.md
+++ b/doc/lightningd-config.5.md
@@ -553,6 +553,10 @@ command, so they invoices can also be paid onchain.
Setting this makes `xpay` wait until all parts have failed/succeeded before returning. Usually this is unnecessary, as xpay will return on the first success (we have the preimage, if they don't take all the parts that's their problem) or failure (the destination could succeed another part, but it would mean it was only partially paid). The default is `false`.
+* **askrene-timeout**=*SECONDS* [plugin `askrene`, *dynamic*]
+
+ This option makes the `getroutes` call fail if it takes more than this many seconds. Setting it to zero is a fun way to ensure your node never makes payments.
+
### Networking options
Note that for simple setups, the implicit *autolisten* option does the
diff --git a/plugins/askrene/askrene.c b/plugins/askrene/askrene.c
index 6e9b35a4..8aebd167 100644
--- a/plugins/askrene/askrene.c
+++ b/plugins/askrene/askrene.c
@@ -614,13 +614,15 @@ static struct command_result *do_getroutes(struct command *cmd,
/* Compute the routes. At this point we might select between multiple
* algorithms. Right now there is only one algorithm available. */
struct timemono time_start = time_mono();
+ struct timemono deadline = timemono_add(time_start,
+ time_from_sec(askrene->route_seconds));
if (info->dev_algo == ALGO_SINGLE_PATH) {
- err = single_path_routes(rq, rq, srcnode, dstnode, info->amount,
+ err = single_path_routes(rq, rq, deadline, srcnode, dstnode, info->amount,
info->maxfee, info->finalcltv,
info->maxdelay, &flows, &probability);
} else {
assert(info->dev_algo == ALGO_DEFAULT);
- err = default_routes(rq, rq, srcnode, dstnode, info->amount,
+ err = default_routes(rq, rq, deadline, srcnode, dstnode, info->amount,
info->maxfee, info->finalcltv,
info->maxdelay, &flows, &probability);
}
@@ -1295,7 +1297,8 @@ static const char *init(struct command *init_cmd,
const char *buf UNUSED, const jsmntok_t *config UNUSED)
{
struct plugin *plugin = init_cmd->plugin;
- struct askrene *askrene = tal(plugin, struct askrene);
+ struct askrene *askrene = get_askrene(plugin);
+
askrene->plugin = plugin;
list_head_init(&askrene->layers);
askrene->reserved = new_reserve_htable(askrene);
@@ -1320,7 +1323,18 @@ static const char *init(struct command *init_cmd,
int main(int argc, char *argv[])
{
+ struct askrene *askrene;
setup_locale();
- plugin_main(argv, init, NULL, PLUGIN_RESTARTABLE, true, NULL, commands, ARRAY_SIZE(commands),
- NULL, 0, NULL, 0, NULL, 0, NULL);
+
+ askrene = tal(NULL, struct askrene);
+ askrene->route_seconds = 10;
+ plugin_main(argv, init, take(askrene), PLUGIN_RESTARTABLE, true, NULL, commands, ARRAY_SIZE(commands),
+ NULL, 0, NULL, 0, NULL, 0,
+ plugin_option_dynamic("askrene-timeout",
+ "int",
+ "How many seconds to try before giving up on calculating a route."
+ " Defaults to 10 seconds",
+ u32_option, u32_jsonfmt,
+ &askrene->route_seconds),
+ NULL);
}
diff --git a/plugins/askrene/askrene.h b/plugins/askrene/askrene.h
index 21805e64..a9a80973 100644
--- a/plugins/askrene/askrene.h
+++ b/plugins/askrene/askrene.h
@@ -34,6 +34,8 @@ struct askrene {
struct node_id my_id;
/* Aux command for layer */
struct command *layer_cmd;
+ /* How long before we abort trying to find a route? */
+ u32 route_seconds;
};
/* Information for a single route query. */
diff --git a/plugins/askrene/mcf.c b/plugins/askrene/mcf.c
index 2bf78b33..e41bfb05 100644
--- a/plugins/askrene/mcf.c
+++ b/plugins/askrene/mcf.c
@@ -1346,6 +1346,7 @@ static bool check_htlc_max_limits(struct route_query *rq, struct flow **flows)
*/
static const char *
linear_routes(const tal_t *ctx, struct route_query *rq,
+ struct timemono deadline,
const struct gossmap_node *srcnode,
const struct gossmap_node *dstnode, struct amount_msat amount,
struct amount_msat maxfee, u32 finalcltv, u32 maxdelay,
@@ -1379,6 +1380,13 @@ linear_routes(const tal_t *ctx, struct route_query *rq,
size_t num_parts, parts_slots, excess_parts;
u32 bottleneck_idx;
+ if (timemono_after(time_mono(), deadline)) {
+ error_message = rq_log(ctx, rq, LOG_BROKEN,
+ "%s: timed out after deadline",
+ __func__);
+ goto fail;
+ }
+
/* FIXME: This algorithm to limit the number of parts is dumb
* for two reasons:
* 1. it does not take into account that several loop
@@ -1641,17 +1649,19 @@ fail:
}
const char *default_routes(const tal_t *ctx, struct route_query *rq,
+ struct timemono deadline,
const struct gossmap_node *srcnode,
const struct gossmap_node *dstnode,
struct amount_msat amount, struct amount_msat maxfee,
u32 finalcltv, u32 maxdelay, struct flow ***flows,
double *probability)
{
- return linear_routes(ctx, rq, srcnode, dstnode, amount, maxfee,
+ return linear_routes(ctx, rq, deadline, srcnode, dstnode, amount, maxfee,
finalcltv, maxdelay, flows, probability, minflow);
}
const char *single_path_routes(const tal_t *ctx, struct route_query *rq,
+ struct timemono deadline,
const struct gossmap_node *srcnode,
const struct gossmap_node *dstnode,
struct amount_msat amount,
@@ -1659,7 +1669,7 @@ const char *single_path_routes(const tal_t *ctx, struct route_query *rq,
u32 maxdelay, struct flow ***flows,
double *probability)
{
- return linear_routes(ctx, rq, srcnode, dstnode, amount, maxfee,
+ return linear_routes(ctx, rq, deadline, srcnode, dstnode, amount, maxfee,
finalcltv, maxdelay, flows, probability,
single_path_flow);
}
diff --git a/plugins/askrene/mcf.h b/plugins/askrene/mcf.h
index 448aee27..7d601590 100644
--- a/plugins/askrene/mcf.h
+++ b/plugins/askrene/mcf.h
@@ -64,6 +64,7 @@ struct amount_msat linear_flow_cost(const struct flow *flow,
/* A wrapper to the min. cost flow solver that actually takes into consideration
* the extra msats per channel needed to pay for fees. */
const char *default_routes(const tal_t *ctx, struct route_query *rq,
+ struct timemono deadline,
const struct gossmap_node *srcnode,
const struct gossmap_node *dstnode,
struct amount_msat amount,
@@ -73,6 +74,7 @@ const char *default_routes(const tal_t *ctx, struct route_query *rq,
/* A wrapper to the single-path constrained solver. */
const char *single_path_routes(const tal_t *ctx, struct route_query *rq,
+ struct timemono deadline,
const struct gossmap_node *srcnode,
const struct gossmap_node *dstnode,
struct amount_msat amount,
diff --git a/tests/test_askrene.py b/tests/test_askrene.py
index 9059d36d..727083e6 100644
--- a/tests/test_askrene.py
+++ b/tests/test_askrene.py
@@ -1185,7 +1185,9 @@ def test_real_data(node_factory, bitcoind):
l1, l2 = node_factory.line_graph(2, fundamount=AMOUNT,
opts=[{'gossip_store_file': outfile.name,
'allow_warning': True,
- 'dev-throttle-gossip': None},
+ 'dev-throttle-gossip': None,
+ # This can be slow!
+ 'askrene-timeout': TIMEOUT},
{'allow_warning': True}])
# These were obviously having a bad day at the time of the snapshot:
@@ -1573,3 +1575,36 @@ def test_maxparts_infloop(node_factory, bitcoind):
maxfee_msat=amount,
final_cltv=5,
maxparts=2)
+
+
+def test_askrene_timeout(node_factory, bitcoind):
+ """Test askrene's route timeout"""
+ l1, l2 = node_factory.line_graph(2, opts=[{'broken_log': 'linear_routes: timed out after deadline'}, {}])
+
+ assert l1.rpc.listconfigs('askrene-timeout')['configs']['askrene-timeout']['value_int'] == 10
+ l1.rpc.getroutes(source=l1.info['id'],
+ destination=l2.info['id'],
+ amount_msat=1,
+ layers=['auto.localchans'],
+ maxfee_msat=1,
+ final_cltv=5)
+
+ # It will exit instantly.
+ l1.rpc.setconfig('askrene-timeout', 0)
+
+ with pytest.raises(RpcError, match='linear_routes: timed out after deadline'):
+ l1.rpc.getroutes(source=l1.info['id'],
+ destination=l2.info['id'],
+ amount_msat=1,
+ layers=['auto.localchans'],
+ maxfee_msat=1,
+ final_cltv=5)
+
+ # We can put it back though.
+ l1.rpc.setconfig('askrene-timeout', 10)
+ l1.rpc.getroutes(source=l1.info['id'],
+ destination=l2.info['id'],
+ amount_msat=1,
+ layers=['auto.localchans'],
+ maxfee_msat=1,
+ final_cltv=5)
Why this scored 46/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.