From 59124bbc26bf0211723b3eb5b5186d3dc7b72b0c Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sat, 8 Aug 2026 17:20:32 +0300 Subject: ipoe: fix ipoe_wlock double release in IPOE_CMD_DELETE ipoe_nl_cmd_delete() drops ipoe_wlock before sleeping in synchronize_rcu() and taking rtnl via unregister_netdev(), then falls through into the out_unlock label and releases it a second time. Every successful session delete therefore increments the semaphore count by one. Since the count only ever grows, ipoe_wlock stops providing mutual exclusion after a handful of teardowns: with the count at N, up to N+1 writers may hold it simultaneously. This is currently masked because ipoe_nl_family does not set parallel_ops, so genetlink serialises every .doit/.dumpit under genl_mutex and no two writers can overlap in practice. It turns into a real race on the session hash lists as soon as that changes, or as soon as a writer is introduced outside the genl handlers. It also silently defeats the barrier in ipoe_fini(): the down()/up() pair meant to wait for an in-flight writer is satisfied immediately by the leaked count and waits for nothing. Return directly after unregister_netdev() so the success path releases the lock exactly once. The early up() is deliberate and stays where it is - holding a sleeping semaphore across unregister_netdev() would nest ipoe_wlock inside rtnl, while ipoe_create() takes rtnl first and ipoe_wlock afterwards. Error paths, return values and lock ordering are unchanged. --- drivers/ipoe/ipoe.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/drivers/ipoe/ipoe.c b/drivers/ipoe/ipoe.c index 2829e6ac..3bf8fef7 100644 --- a/drivers/ipoe/ipoe.c +++ b/drivers/ipoe/ipoe.c @@ -1399,6 +1399,8 @@ static int ipoe_nl_cmd_delete(struct sk_buff *skb, struct genl_info *info) if (ses->u.hwaddr_u) list_del_rcu(&ses->entry3); + /* drop the lock before sleeping in synchronize_rcu() and taking rtnl + * in unregister_netdev() */ up(&ipoe_wlock); synchronize_rcu(); @@ -1411,7 +1413,7 @@ static int ipoe_nl_cmd_delete(struct sk_buff *skb, struct genl_info *info) unregister_netdev(ses->dev); - ret = 0; + return 0; out_unlock: up(&ipoe_wlock); -- cgit v1.2.3 From af009c87b41ea1bb594d68e53d46ca050057e2db Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sat, 8 Aug 2026 17:45:35 +0300 Subject: ipoe: zero generic netlink requests before filling them in The request buffers are plain stack variables and only the nlmsghdr fields and genlmsghdr.cmd were ever assigned, so genlmsghdr.version and genlmsghdr.reserved reached the kernel holding whatever happened to be on the stack. Since 6.1 genetlink validates the reserved header fields of every command whose id is >= genl_family.resv_start_op, and ipoe sets that field to CTRL_CMD_GETPOLICY + 1, i.e. 11. IPOE_CMD_DEL_NET is 11, so ipoe_nl_del_net(), which runs on startup and on every config reload, is already rejected with EINVAL whenever that garbage is nonzero, and any command added after it is affected as well. --- accel-pppd/ctrl/ipoe/ipoe_netlink.c | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/accel-pppd/ctrl/ipoe/ipoe_netlink.c b/accel-pppd/ctrl/ipoe/ipoe_netlink.c index 7057c91e..94b7f42a 100644 --- a/accel-pppd/ctrl/ipoe/ipoe_netlink.c +++ b/accel-pppd/ctrl/ipoe/ipoe_netlink.c @@ -46,6 +46,8 @@ int ipoe_nl_add_exclude(uint32_t addr, int mask) return -1; } + memset(&req, 0, sizeof(req)); + nlh = &req.n; nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN); nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; @@ -82,6 +84,8 @@ void ipoe_nl_del_exclude(uint32_t addr) return; } + memset(&req, 0, sizeof(req)); + nlh = &req.n; nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN); nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; @@ -114,6 +118,8 @@ int ipoe_nl_add_net(uint32_t addr, int mask) return -1; } + memset(&req, 0, sizeof(req)); + nlh = &req.n; nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN); nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; @@ -150,6 +156,8 @@ void ipoe_nl_del_net(uint32_t addr) return; } + memset(&req, 0, sizeof(req)); + nlh = &req.n; nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN); nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; @@ -181,6 +189,8 @@ void ipoe_nl_add_interface(int ifindex, uint8_t mode) return; } + memset(&req, 0, sizeof(req)); + nlh = &req.n; nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN); nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; @@ -213,6 +223,8 @@ void ipoe_nl_del_interface(int ifindex) return; } + memset(&req, 0, sizeof(req)); + nlh = &req.n; nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN); nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; @@ -253,6 +265,8 @@ int ipoe_nl_create() return -1; } + memset(&req, 0, sizeof(req)); + nlh = &req.n; nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN); nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; @@ -317,6 +331,8 @@ int ipoe_nl_modify(int ifindex, uint32_t peer_addr, uint32_t addr, uint32_t gw, return -1; } + memset(&req, 0, sizeof(req)); + nlh = &req.n; nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN); nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; @@ -406,6 +422,8 @@ void ipoe_nl_get_sessions(struct list_head *list) if (rth.fd == -1) return; + memset(&req, 0, sizeof(req)); + nlh = &req.n; nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN); nlh->nlmsg_flags = NLM_F_ROOT | NLM_F_MATCH | NLM_F_REQUEST; @@ -438,6 +456,8 @@ void ipoe_nl_delete(int ifindex) return; } + memset(&req, 0, sizeof(req)); + nlh = &req.n; nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN); nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; -- cgit v1.2.3 From b2eab395bb283b412e7bc8fef022ac06721b9a4e Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sat, 8 Aug 2026 17:46:14 +0300 Subject: libnetlink: report the genl family id even when the group is not found genl_resolve_mcg() stored the resolved family id only after it had established that the family advertises multicast groups, so a caller that also needs the family id was left with nothing whenever the group lookup failed. Fill in fam_id as soon as it has been parsed. The return value is unchanged, so callers interested only in the group are unaffected. --- accel-pppd/libnetlink/genl.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/accel-pppd/libnetlink/genl.c b/accel-pppd/libnetlink/genl.c index 0c758077..8cdfce89 100644 --- a/accel-pppd/libnetlink/genl.c +++ b/accel-pppd/libnetlink/genl.c @@ -82,12 +82,14 @@ int __export genl_resolve_mcg(const char *family, const char *name, int *fam_id) goto out; } - if (!tb[CTRL_ATTR_MCAST_GROUPS]) - goto out; - + /* report the family id even if the group lookup below fails, the + * caller may still have use for it */ if (fam_id) *fam_id = *(uint16_t *)(RTA_DATA(tb[CTRL_ATTR_FAMILY_ID])); + if (!tb[CTRL_ATTR_MCAST_GROUPS]) + goto out; + parse_rtattr_nested(tb2, GENL_MAX_FAM_GRPS, tb[CTRL_ATTR_MCAST_GROUPS]); for (i = 1; i < GENL_MAX_FAM_GRPS; i++) { -- cgit v1.2.3 From 4fb21bfd967ee834d2b5d5ea11d0838002d6ac1a Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sat, 8 Aug 2026 17:46:44 +0300 Subject: ipoe: do not skip a session when a dump spans several messages ipoe_nl_cmd_dump_sessions() increments idx before calling fill_info(), so once fill_info() fails because the skb is full, idx already points past the session that did not fit. cb->args[0] is set to that value and the next round resumes one entry too far, dropping the session from the dump entirely - one lost session per message boundary, roughly one in every 90 at the current record size. Step idx back before leaving the loop. --- drivers/ipoe/ipoe.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/drivers/ipoe/ipoe.c b/drivers/ipoe/ipoe.c index 3bf8fef7..73926994 100644 --- a/drivers/ipoe/ipoe.c +++ b/drivers/ipoe/ipoe.c @@ -1579,7 +1579,11 @@ static int ipoe_nl_cmd_dump_sessions(struct sk_buff *skb, struct netlink_callbac #else if (fill_info(skb, ses, NETLINK_CB(cb->skb).portid, cb->nlh->nlmsg_seq) < 0) #endif + { + /* this one did not fit, resume from it next time */ + idx--; break; + } } up(&ipoe_wlock); -- cgit v1.2.3 From 6693d18ae0232da2c6febc0decf099e4fd064381 Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sat, 8 Aug 2026 17:47:49 +0300 Subject: ipoe: flush sessions left by a previous instance with a single command On startup accel-pppd is expected to drop everything a previous instance left behind in the kernel. For sessions it did so by dumping them with IPOE_CMD_GET and sending one IPOE_CMD_DELETE per ifindex. That dump was written for the session backup code removed in 1972a7e5c and was never adapted to its new, destructive role: - it was issued on the socket subscribed to the packet multicast group, so it competed with the notifications that the still attached stale rx handlers keep generating; on a loaded box the receive buffer overruns and rtnl_dump_filter() gives up with ENOBUFS, - its return value was discarded, so such a failure was silent, - it ran before the interfaces were detached, which is what produces those notifications in the first place, - it was skipped entirely when the multicast group could not be resolved, again with nothing but a warning about packet handling, - and every session cost a socket, a round trip, a grace period and a full unregister_netdev(). Whatever it missed stays in the kernel forever: the daemon has no record of those sessions, so nothing ever deletes them, and every subscriber later assigned one of their addresses is refused with EEXIST by IPOE_CMD_MODIFY. Add IPOE_CMD_FLUSH, which unlinks all sessions in one go, waits for a single grace period and unregisters the devices with unregister_netdevice_many(), and use it instead. The old path is kept as a fallback for a module predating the command, which is reported as EOPNOTSUPP, and now checks its return value and uses a private socket. Reorder init() so the interfaces are detached before the multicast group is joined, and so the flush also runs when only the group lookup failed. --- accel-pppd/ctrl/ipoe/ipoe.h | 3 +- accel-pppd/ctrl/ipoe/ipoe_netlink.c | 101 +++++++++++++++++++++++++++++++----- drivers/ipoe/ipoe.c | 54 +++++++++++++++++++ drivers/ipoe/ipoe.h | 1 + 4 files changed, 146 insertions(+), 13 deletions(-) diff --git a/accel-pppd/ctrl/ipoe/ipoe.h b/accel-pppd/ctrl/ipoe/ipoe.h index 37c26a63..2bd3a14f 100644 --- a/accel-pppd/ctrl/ipoe/ipoe.h +++ b/accel-pppd/ctrl/ipoe/ipoe.h @@ -164,7 +164,8 @@ void ipoe_nl_delete_interfaces(void); int ipoe_nl_create(); void ipoe_nl_delete(int ifindex); int ipoe_nl_modify(int ifindex, uint32_t peer_addr, uint32_t addr, uint32_t gw, int link_ifindex, uint8_t *hwaddr); -void ipoe_nl_get_sessions(struct list_head *list); +int ipoe_nl_get_sessions(struct list_head *list); +int ipoe_nl_flush_sessions(void); int ipoe_nl_add_exclude(uint32_t addr, int mask); void ipoe_nl_del_exclude(uint32_t addr); int ipoe_nl_add_net(uint32_t addr, int mask); diff --git a/accel-pppd/ctrl/ipoe/ipoe_netlink.c b/accel-pppd/ctrl/ipoe/ipoe_netlink.c index 94b7f42a..6eee2dca 100644 --- a/accel-pppd/ctrl/ipoe/ipoe_netlink.c +++ b/accel-pppd/ctrl/ipoe/ipoe_netlink.c @@ -410,17 +410,23 @@ static int dump_session(const struct sockaddr_nl *addr, struct nlmsghdr *n, void return 0; } -void ipoe_nl_get_sessions(struct list_head *list) +int ipoe_nl_get_sessions(struct list_head *list) { + struct rtnl_handle rth; struct nlmsghdr *nlh; struct genlmsghdr *ghdr; struct { struct nlmsghdr n; char buf[1024]; } req; + int ret; - if (rth.fd == -1) - return; + /* a private socket, so that the dump does not have to compete with + * the packet notifications delivered to the multicast one */ + if (rtnl_open_byproto(&rth, 0, NETLINK_GENERIC)) { + log_error("ipoe: cannot open generic netlink socket\n"); + return -1; + } memset(&req, 0, sizeof(req)); @@ -434,11 +440,51 @@ void ipoe_nl_get_sessions(struct list_head *list) ghdr->cmd = IPOE_CMD_GET; if (rtnl_send(&rth, (char *)nlh, nlh->nlmsg_len) < 0) { - log_emerg("ipoe: failed to send dump request: %s\n", strerror(errno)); - return; + log_error("ipoe: failed to send dump request: %s\n", strerror(errno)); + rtnl_close(&rth); + return -1; + } + + ret = rtnl_dump_filter(&rth, dump_session, list, NULL, NULL); + + rtnl_close(&rth); + + return ret; +} + +int ipoe_nl_flush_sessions(void) +{ + struct rtnl_handle rth; + struct nlmsghdr *nlh; + struct genlmsghdr *ghdr; + struct { + struct nlmsghdr n; + char buf[128]; + } req; + int ret = 0; + + if (rtnl_open_byproto(&rth, 0, NETLINK_GENERIC)) { + log_error("ipoe: cannot open generic netlink socket\n"); + errno = EIO; + return -1; } - rtnl_dump_filter(&rth, dump_session, list, NULL, NULL); + memset(&req, 0, sizeof(req)); + + nlh = &req.n; + nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN); + nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; + nlh->nlmsg_type = ipoe_genl_id; + + ghdr = NLMSG_DATA(&req.n); + ghdr->cmd = IPOE_CMD_FLUSH; + + if (rtnl_talk(&rth, nlh, 0, 0, nlh, NULL, NULL, 0) < 0) + ret = -1; + + rtnl_close(&rth); + + return ret; } void ipoe_nl_delete(int ifindex) @@ -479,7 +525,10 @@ static void delete_sessions() struct ipoe_session_info *info; LIST_HEAD(ds_list); - ipoe_nl_get_sessions(&ds_list); + + if (ipoe_nl_get_sessions(&ds_list)) + log_error("ipoe: failed to enumerate sessions left by a previous" + " instance, some of them are not removed\n"); while (!list_empty(&ds_list)) { info = list_entry(ds_list.next, typeof(*info), entry); @@ -489,6 +538,22 @@ static void delete_sessions() } } +static void flush_sessions() +{ + if (!ipoe_nl_flush_sessions()) + return; + + if (errno == EOPNOTSUPP) { + log_warn("ipoe: loaded ipoe module does not support IPOE_CMD_FLUSH," + " removing sessions one by one, reload the module to fix\n"); + delete_sessions(); + return; + } + + log_error("ipoe: failed to remove sessions left by a previous instance:" + " %s\n", strerror(errno)); +} + static void ipoe_up_handler(const struct sockaddr_nl *addr, struct nlmsghdr *h) { struct rtattr *tb[PKT_ATTR_MAX + 1]; @@ -648,6 +713,23 @@ static void init(void) log_warn("failed to load ipoe module\n"); mcg_id = genl_resolve_mcg(IPOE_GENL_NAME, IPOE_GENL_MCG_PKT, &ipoe_genl_id); + + if (!ipoe_genl_id) { + log_error("ipoe: cannot resolve netlink family, state left by a" + " previous instance is not removed\n"); + rth.fd = -1; + return; + } + + /* Drop everything a previous instance may have left in the kernel. + * The interfaces go first: while their rx handlers are still attached + * the module keeps reporting unclassified packets, and once we join + * the multicast group that traffic competes with our own replies. */ + ipoe_nl_delete_interfaces(); + flush_sessions(); + ipoe_nl_del_exclude(0); + ipoe_nl_del_net(0); + if (mcg_id == -1) { log_warn("ipoe: unclassified packet handling is disabled\n"); rth.fd = -1; @@ -660,11 +742,6 @@ static void init(void) return; } - delete_sessions(); - ipoe_nl_del_exclude(0); - ipoe_nl_del_net(0); - ipoe_nl_delete_interfaces(); - fcntl(rth.fd, F_SETFL, O_NONBLOCK); fcntl(rth.fd, F_SETFD, fcntl(rth.fd, F_GETFD) | FD_CLOEXEC); diff --git a/drivers/ipoe/ipoe.c b/drivers/ipoe/ipoe.c index 73926994..8f1c852b 100644 --- a/drivers/ipoe/ipoe.c +++ b/drivers/ipoe/ipoe.c @@ -1420,6 +1420,52 @@ out_unlock: return ret; } +static int ipoe_nl_cmd_flush(struct sk_buff *skb, struct genl_info *info) +{ + struct ipoe_session *ses; + LIST_HEAD(list); + LIST_HEAD(kill_list); + + down(&ipoe_wlock); + + list_splice_init(&ipoe_list2, &list); + + list_for_each_entry(ses, &list, entry2) { + if (ses->peer_addr) + list_del_rcu(&ses->entry); + if (ses->u.hwaddr_u) + list_del_rcu(&ses->entry3); + } + + up(&ipoe_wlock); + + if (list_empty(&list)) + return 0; + + /* a single grace period covers the whole batch */ + synchronize_rcu(); + + list_for_each_entry(ses, &list, entry2) { + while (atomic_read(&ses->refs)) + schedule_timeout_uninterruptible(1); + + if (ses->link_dev) { + dev_put(ses->link_dev); + ses->link_dev = NULL; + } + } + + rtnl_lock(); + list_for_each_entry(ses, &list, entry2) + unregister_netdevice_queue(ses->dev, &kill_list); + unregister_netdevice_many(&kill_list); + rtnl_unlock(); + + /* the sessions are freed by now, do not touch 'list' again */ + + return 0; +} + static int ipoe_nl_cmd_modify(struct sk_buff *skb, struct genl_info *info) { int ret = -EINVAL, r = 0; @@ -1894,6 +1940,14 @@ static const struct genl_ops ipoe_nl_ops[] = { .flags = GENL_ADMIN_PERM, #if LINUX_VERSION_CODE < KERNEL_VERSION(5,2,0) .policy = ipoe_nl_policy, +#endif + }, + { + .cmd = IPOE_CMD_FLUSH, + .doit = ipoe_nl_cmd_flush, + .flags = GENL_ADMIN_PERM, +#if LINUX_VERSION_CODE < KERNEL_VERSION(5,2,0) + .policy = ipoe_nl_policy, #endif }, }; diff --git a/drivers/ipoe/ipoe.h b/drivers/ipoe/ipoe.h index 4097e2da..2d041c50 100644 --- a/drivers/ipoe/ipoe.h +++ b/drivers/ipoe/ipoe.h @@ -16,6 +16,7 @@ enum { IPOE_CMD_DEL_EXCLUDE, IPOE_CMD_ADD_NET, IPOE_CMD_DEL_NET, + IPOE_CMD_FLUSH, __IPOE_CMD_MAX, }; -- cgit v1.2.3 From 4969b65c3f55c739a0c9178086f42ba3de906119 Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sat, 8 Aug 2026 18:12:56 +0300 Subject: ipoe: allow session interfaces to be removed with ip link del Sessions left in the kernel by a dead daemon could only be got rid of by restarting accel-pppd, which flushes all of them and so drops everyone still online, or by unloading the module. Neither is of much use when a single stale interface is holding an address and every subscriber that is later handed it is refused with EEXIST. Register rtnl_link_ops, so that RTM_DELLINK reaches the driver: ip link show type ipoe enumerate them ip -d link show ipoe42 reports kind "ipoe" ip link del ipoe42 remove a single one newlink returns EOPNOTSUPP rather than being left out. A session carries private state that IPOE_CMD_CREATE sets up, and a kernel that falls back to register_netdevice() when newlink is NULL would hand out a device with zeroed private state and no percpu counters, which oopses on the first packet or on ip -s link show. dellink runs under rtnl and outside genl_mutex, and is therefore the first writer in this driver that does not serialise against the genl handlers: it can unlink a session that IPOE_CMD_DELETE is about to unlink as well, since the device stays registered until both are done. Add ipoe_session.dying, set under ipoe_wlock by whichever path starts the teardown, and let the other one bail out. That leaves rtnl nested inside ipoe_wlock, which is safe because no path does it the other way round: ipoe_create() releases rtnl before taking ipoe_wlock, ipoe_nl_cmd_delete() releases ipoe_wlock before calling unregister_netdev(), and the interface commands use rtnl alone. dellink does have to sleep in synchronize_rcu() and while draining the session refcount with rtnl held, which the genl path avoids by deferring both until after it has dropped everything. A session left behind by a dead daemon is indistinguishable from one that is still in use - same peer address, same lists, same flags - so the removal cannot be refused on state alone. Warn instead, and only when somebody is still subscribed to the packet multicast group, so that clearing stale interfaces on a box where accel-pppd is not running stays quiet. --- drivers/ipoe/ipoe.c | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/drivers/ipoe/ipoe.c b/drivers/ipoe/ipoe.c index 8f1c852b..a9b11fa2 100644 --- a/drivers/ipoe/ipoe.c +++ b/drivers/ipoe/ipoe.c @@ -104,6 +104,10 @@ struct ipoe_session { atomic_t refs; + /* set under ipoe_wlock by whoever starts tearing the session down, + * so that the genl and the rtnetlink path do not do it twice */ + unsigned int dying:1; + struct ipoe_stats __percpu *rx_stats; struct ipoe_stats __percpu *tx_stats; }; @@ -1116,6 +1120,83 @@ static const struct header_ops ipoe_hard_header_ops = { .cache_update = eth_header_cache_update, }; +/* Is anybody subscribed to our packet group, i.e. is a control daemon + * running at all? */ +static int ipoe_ctrl_attached(void) +{ +#if LINUX_VERSION_CODE < KERNEL_VERSION(3,13,0) && RHEL_MAJOR < 7 + return netlink_has_listeners(init_net.genl_sock, ipoe_nl_mcg.id); +#else + return genl_has_listeners(&ipoe_nl_family, &init_net, 0); +#endif +} + +/* Sessions are created through IPOE_CMD_CREATE, which also sets up the + * private state. A device made by rtnetlink would have none of it, so + * refuse 'ip link add ... type ipoe' explicitly. */ +#if LINUX_VERSION_CODE >= KERNEL_VERSION(6,13,0) +static int ipoe_newlink(struct net_device *dev, + struct rtnl_newlink_params *params, + struct netlink_ext_ack *extack) +#elif LINUX_VERSION_CODE >= KERNEL_VERSION(4,13,0) +static int ipoe_newlink(struct net *src_net, struct net_device *dev, + struct nlattr *tb[], struct nlattr *data[], + struct netlink_ext_ack *extack) +#else +static int ipoe_newlink(struct net *src_net, struct net_device *dev, + struct nlattr *tb[], struct nlattr *data[]) +#endif +{ + return -EOPNOTSUPP; +} + +/* Called by rtnetlink with rtnl held, so the teardown ipoe_nl_cmd_delete() + * does after dropping ipoe_wlock has to happen here as well. */ +static void ipoe_dellink(struct net_device *dev, struct list_head *head) +{ + struct ipoe_session *ses = netdev_priv(dev); + + down(&ipoe_wlock); + + if (ses->dying) { + /* the genl path is already removing it, it will unregister + * the device itself once it gets rtnl */ + up(&ipoe_wlock); + return; + } + + ses->dying = 1; + + if (ses->peer_addr) + list_del_rcu(&ses->entry); + list_del(&ses->entry2); + if (ses->u.hwaddr_u) + list_del_rcu(&ses->entry3); + + up(&ipoe_wlock); + + /* A session left behind by a dead daemon looks exactly like one that + * is still in use, so removing it can not be refused on state alone. + * Complain only if somebody is still subscribed to our multicast + * group, which means a daemon is around to be surprised by it. */ + if (ses->peer_addr && ipoe_ctrl_attached()) + pr_warn("ipoe: %s: removed through rtnetlink while bound to %pI4" + " and a control daemon is attached\n", + dev->name, &ses->peer_addr); + + synchronize_rcu(); + + while (atomic_read(&ses->refs)) + schedule_timeout_uninterruptible(1); + + if (ses->link_dev) { + dev_put(ses->link_dev); + ses->link_dev = NULL; + } + + unregister_netdevice_queue(dev, head); +} + static void ipoe_netdev_setup(struct net_device *dev) { dev->netdev_ops = &ipoe_netdev_ops; @@ -1145,6 +1226,14 @@ static void ipoe_netdev_setup(struct net_device *dev) dev->priv_flags &= ~IFF_XMIT_DST_RELEASE; } +static struct rtnl_link_ops ipoe_link_ops __read_mostly = { + .kind = "ipoe", + .priv_size = sizeof(struct ipoe_session), + .setup = ipoe_netdev_setup, + .newlink = ipoe_newlink, + .dellink = ipoe_dellink, +}; + static int ipoe_create(__be32 peer_addr, __be32 addr, __be32 gw, int ifindex, const __u8 *hwaddr) { struct ipoe_session *ses; @@ -1220,6 +1309,7 @@ static int ipoe_create(__be32 peer_addr, __be32 addr, __be32 gw, int ifindex, co }*/ dev->tx_queue_len = 100; + dev->rtnl_link_ops = &ipoe_link_ops; rtnl_lock(); r = register_netdevice(dev); @@ -1393,6 +1483,14 @@ static int ipoe_nl_cmd_delete(struct sk_buff *skb, struct genl_info *info) //pr_info("ipoe: delete %08x\n", ses->peer_addr); + if (ses->dying) { + /* already on its way out through ipoe_dellink() or a flush */ + ret = 0; + goto out_unlock; + } + + ses->dying = 1; + if (ses->peer_addr) list_del_rcu(&ses->entry); list_del(&ses->entry2); @@ -1431,6 +1529,8 @@ static int ipoe_nl_cmd_flush(struct sk_buff *skb, struct genl_info *info) list_splice_init(&ipoe_list2, &list); list_for_each_entry(ses, &list, entry2) { + ses->dying = 1; + if (ses->peer_addr) list_del_rcu(&ses->entry); if (ses->u.hwaddr_u) @@ -2020,6 +2120,12 @@ static int __init ipoe_init(void) skb_queue_head_init(&ipoe_queue); INIT_WORK(&ipoe_queue_work, ipoe_process_queue); + err = rtnl_link_register(&ipoe_link_ops); + if (err < 0) { + printk(KERN_INFO "ipoe: can't register link operations\n"); + return err; + } + #if LINUX_VERSION_CODE < KERNEL_VERSION(3,13,0) && RHEL_MAJOR < 7 err = genl_register_family_with_ops(&ipoe_nl_family, ipoe_nl_ops, ARRAY_SIZE(ipoe_nl_ops)); #elif LINUX_VERSION_CODE < KERNEL_VERSION(4,10,0) @@ -2029,6 +2135,7 @@ static int __init ipoe_init(void) #endif if (err < 0) { printk(KERN_INFO "ipoe: can't register netlink interface\n"); + rtnl_link_unregister(&ipoe_link_ops); return err; } @@ -2037,6 +2144,7 @@ static int __init ipoe_init(void) if (err < 0) { printk(KERN_INFO "ipoe: can't register netlink multicast group\n"); genl_unregister_family(&ipoe_nl_family); + rtnl_link_unregister(&ipoe_link_ops); return err; } #endif @@ -2056,6 +2164,10 @@ static void __exit ipoe_fini(void) #endif genl_unregister_family(&ipoe_nl_family); + /* takes down whatever sessions are left through ipoe_dellink() and + * keeps rtnetlink from starting another one behind our back */ + rtnl_link_unregister(&ipoe_link_ops); + down(&ipoe_wlock); up(&ipoe_wlock); -- cgit v1.2.3 From d22f698e88a05332cee4b8dac2e7085c8b83b0ed Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sat, 8 Aug 2026 18:22:47 +0300 Subject: tests: cover the removal of stale ipoe session interfaces Three tests around the interfaces the ipoe module creates per session: - kill accel-pppd with SIGKILL while a dhcp session is up, start it again and check that the interfaces left behind by the killed instance are gone, - remove a session interface with 'ip link del', - check that 'ip link add ... type ipoe' is refused, since a device made through rtnetlink would have none of the private state that IPOE_CMD_CREATE sets up. They carry the ipoe_driver marker and sit next to the existing ipoe tests, so the workflows run them in the steps that follow the insmod of the module. No workflow change is needed. The [modules] section has to list connlimit and radius before ipoe: libipoe.so refers to symbols of both, and with a strict dynamic linker loading it on its own fails with a relocation error instead of a missing feature. The tests assert that accel-pppd came up, so that this kind of misconfiguration is not reported as an unrelated cli connection failure. Restarting accel-pppd needs no explicit synchronisation: triton_load_modules() runs every DEFINE_INIT() before triton_run() starts the threads that serve the cli, so by the time accel-cmd 'show version' is answered the flush registered at DEFINE_INIT(19) has already run. Interfaces are compared by ifindex and not by name, because dhclient may get a new session in the meantime and the fresh interface would reuse the ipoe0 name. The check that nothing removes the interfaces while no accel-pppd is running is only printed, not asserted, so that the module is free to start doing it on its own later on. --- tests/accel-pppd/ipoe/test_ipoe_link_ops.py | 82 ++++++++++++++++++++ tests/accel-pppd/ipoe/test_ipoe_stale_cleanup.py | 97 ++++++++++++++++++++++++ tests/common/ipoe_iface.py | 84 ++++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 tests/accel-pppd/ipoe/test_ipoe_link_ops.py create mode 100644 tests/accel-pppd/ipoe/test_ipoe_stale_cleanup.py create mode 100644 tests/common/ipoe_iface.py diff --git a/tests/accel-pppd/ipoe/test_ipoe_link_ops.py b/tests/accel-pppd/ipoe/test_ipoe_link_ops.py new file mode 100644 index 00000000..00d06af4 --- /dev/null +++ b/tests/accel-pppd/ipoe/test_ipoe_link_ops.py @@ -0,0 +1,82 @@ +import pytest +from common import process, ipoe_iface + + +@pytest.fixture() +def accel_pppd_config(veth_pair_netns): + return ( + """ + [modules] + connlimit + radius + ipoe + ippool + + [ip-pool] + gw-ip-address=192.0.2.1 + 192.0.2.2-255 + + [cli] + tcp=127.0.0.1:2001 + + [core] + log-error=/dev/stderr + + [log] + log-debug=/dev/stdout + log-file=/dev/stdout + log-emerg=/dev/stderr + level=5 + + [radius] + + [ipoe] + noauth=1 + shared=1 + gw-ip-address=192.0.2.1/24 + interface=re:.""" + + veth_pair_netns["veth_a"][1:] + ) + + +# sessions carry private state set up over generic netlink, a device made by +# rtnetlink would have none of it +@pytest.mark.dependency(depends=["ipoe_driver_loaded"], scope="session") +@pytest.mark.ipoe_driver +def test_ipoe_interface_cannot_be_created_by_iproute(): + (exit_code, out, err) = process.run( + ["ip", "link", "add", "ipoetest0", "type", "ipoe"] + ) + print("ip link add: exit=%d out=%s err=%s" % (exit_code, out, err)) + + # remove it again in case it got created anyway, so that the rest of the + # suite is not affected + process.run(["ip", "link", "del", "ipoetest0"]) + + assert exit_code != 0 + + +# stale interfaces have to be removable without restarting accel-pppd, which +# would drop every remaining session +@pytest.mark.dependency(depends=["ipoe_driver_loaded"], scope="session") +@pytest.mark.ipoe_driver +def test_ipoe_interface_can_be_deleted_by_iproute( + accel_pppd_instance, dhclient_instance, accel_cmd, veth_pair_netns +): + assert accel_pppd_instance, "accel-pppd did not start" + assert dhclient_instance["is_started"] + assert ipoe_iface.wait_for_session(accel_cmd, veth_pair_netns["veth_a"]) + + before = ipoe_iface.list_ipoe() + print("ipoe interfaces: " + str(before)) + assert len(before) > 0 + + (ifindex, name) = before[0] + + (exit_code, out, err) = process.run(["ip", "link", "del", name]) + print("ip link del %s: exit=%d out=%s err=%s" % (name, exit_code, out, err)) + assert exit_code == 0 + + after = ipoe_iface.list_ipoe() + print("ipoe interfaces after delete: " + str(after)) + assert (ifindex, name) not in after diff --git a/tests/accel-pppd/ipoe/test_ipoe_stale_cleanup.py b/tests/accel-pppd/ipoe/test_ipoe_stale_cleanup.py new file mode 100644 index 00000000..b7cfd533 --- /dev/null +++ b/tests/accel-pppd/ipoe/test_ipoe_stale_cleanup.py @@ -0,0 +1,97 @@ +import pytest +from common import accel_pppd_process, ipoe_iface + + +@pytest.fixture() +def accel_pppd_config(veth_pair_netns): + return ( + """ + [modules] + connlimit + radius + ipoe + ippool + + [ip-pool] + gw-ip-address=192.0.2.1 + 192.0.2.2-255 + + [cli] + tcp=127.0.0.1:2001 + + [core] + log-error=/dev/stderr + + [log] + log-debug=/dev/stdout + log-file=/dev/stdout + log-emerg=/dev/stderr + level=5 + + [radius] + + [ipoe] + noauth=1 + shared=1 + gw-ip-address=192.0.2.1/24 + interface=re:.""" + + veth_pair_netns["veth_a"][1:] + ) + + +# accel-pppd killed with SIGKILL has no chance to remove the session +# interfaces it created, so they are left in the kernel. The next instance is +# supposed to drop them while starting up. +@pytest.mark.dependency(depends=["ipoe_driver_loaded"], scope="session") +@pytest.mark.ipoe_driver +def test_ipoe_stale_interfaces_removed_after_sigkill( + accel_pppd_instance, + dhclient_instance, + accel_cmd, + accel_pppd, + accel_pppd_config_file, + veth_pair_netns, + pytestconfig, +): + assert accel_pppd_instance, "accel-pppd did not start" + assert dhclient_instance["is_started"] + assert ipoe_iface.wait_for_session(accel_cmd, veth_pair_netns["veth_a"]) + + # the session must have created an interface, otherwise there is nothing + # for this test to check + before = ipoe_iface.list_ipoe() + print("ipoe interfaces before the crash: " + str(before)) + assert len(before) > 0 + + assert ipoe_iface.kill_accel_pppd() > 0 + + # nothing removes them while no accel-pppd is running (not asserted, the + # kernel module is free to start doing it on its own one day) + print("ipoe interfaces after the crash: " + str(ipoe_iface.list_ipoe())) + + # start again, it replies to 'show version' only once the startup flush is + # done, so there is no need to wait for anything else + (is_started, thread, control) = accel_pppd_process.start( + accel_pppd, + ["-c" + accel_pppd_config_file], + accel_cmd, + pytestconfig.getoption("accel_pppd_max_wait_time"), + ) + + try: + assert is_started + + after = ipoe_iface.list_ipoe() + print("ipoe interfaces after the restart: " + str(after)) + + # compared by ifindex: dhclient may get a new session in the meantime, + # and the fresh interface would reuse the ipoe0 name + stale = set(before) & set(after) + assert not stale, "interfaces left over from the killed instance: " + str(stale) + finally: + accel_pppd_process.end( + thread, + control, + accel_cmd, + pytestconfig.getoption("accel_pppd_max_finish_time"), + ) diff --git a/tests/common/ipoe_iface.py b/tests/common/ipoe_iface.py new file mode 100644 index 00000000..62b26355 --- /dev/null +++ b/tests/common/ipoe_iface.py @@ -0,0 +1,84 @@ +from common import process +import os +import signal +import time + + +# (ifindex, name) of every ipoe session interface currently in the kernel. +# Matched by name rather than by 'ip link show type ipoe' so that the helper +# keeps working with a module that does not register rtnl_link_ops. +def list_ipoe(): + (exit_code, out, err) = process.run(["ip", "-o", "link", "show"]) + assert exit_code == 0, "ip link show failed: " + err + + ifaces = [] + for line in out.splitlines(): + fields = line.split(":") + if len(fields) < 2: + continue + try: + ifindex = int(fields[0].strip()) + except ValueError: + continue + name = fields[1].strip().split("@")[0] + if name.startswith("ipoe"): + ifaces.append((ifindex, name)) + + return ifaces + + +# pids of the running accel-pppd processes +def accel_pppd_pids(): + pids = [] + for entry in os.listdir("/proc"): + if not entry.isdigit(): + continue + try: + with open("/proc/" + entry + "/comm") as comm: + if comm.read().strip() == "accel-pppd": + pids.append(int(entry)) + except OSError: # process is gone, or not ours to look at + pass + + return pids + + +# SIGKILL every accel-pppd and wait until they are really gone. +# Returns the number of processes that were killed. +def kill_accel_pppd(max_wait_time=10.0): + pids = accel_pppd_pids() + for pid in pids: + print("kill_accel_pppd: SIGKILL to pid " + str(pid)) + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + sleep_time = 0.0 + while sleep_time < max_wait_time: + if not accel_pppd_pids(): + print("kill_accel_pppd: gone in (sec): " + str(sleep_time)) + break + time.sleep(0.1) + sleep_time += 0.1 + + return len(pids) + + +# wait until accel-pppd reports an active ipoe session on the given interface +def wait_for_session(accel_cmd, called_sid, max_wait_time=10.0): + sleep_time = 0.0 + out = "" + while sleep_time < max_wait_time: + (exit_code, out, err) = process.run( + [accel_cmd, "show sessions called-sid,ip,state"] + ) + assert exit_code == 0, "accel-cmd failed: " + err + if called_sid in out and "192.0.2." in out and "active" in out: + print("wait_for_session: session found in (sec): " + str(sleep_time)) + return True + time.sleep(0.1) + sleep_time += 0.1 + + print("wait_for_session: last accel-cmd out: " + out) + return False -- cgit v1.2.3 From 9015abafb60f950a6b1a800f84cbf569b07828a1 Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sat, 8 Aug 2026 19:12:49 +0300 Subject: ipoe: include net/rtnetlink.h, insert modules in ci even after a failure rtnl_link_register() and struct rtnl_link_ops were reaching the driver through some other header rather than through net/rtnetlink.h, which is the kind of thing that only shows up when building against a different kernel. Include it directly. The workflows insert the kernel modules between two pytest runs, and the runs that follow are marked 'if: always()' while the insmod steps are not. A failure in an earlier, unrelated test therefore skips the insmod but still runs the tests that need the module, which then report a missing driver instead of the original problem. Mark the insmod steps 'if: always()' as well, so that the later runs test what they are supposed to. --- .github/workflows/run-tests.yml | 6 ++++++ drivers/ipoe/ipoe.c | 1 + 2 files changed, 7 insertions(+) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 9cd47a00..10f238cc 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -145,6 +145,7 @@ jobs: sudo dmesg" - name: Insert ipoe kernel module + if: ${{ always() }} run: > ssh -i ssh-key -p2222 user@localhost "cd accel-ppp && sudo insmod build/drivers/ipoe/driver/ipoe.ko && @@ -163,12 +164,14 @@ jobs: sudo dmesg" - name: Insert vlan_mon kernel module + if: ${{ always() }} run: > ssh -i ssh-key -p2222 user@localhost "cd accel-ppp && sudo insmod build/drivers/vlan_mon/driver/vlan_mon.ko && lsmod | grep vlan_mon" - name: Insert ppposeq kernel module + if: ${{ always() }} run: > ssh -i ssh-key -p2222 user@localhost "cd accel-ppp && sudo modprobe pppox && sudo insmod build/drivers/ppposeq/driver/ppposeq.ko && @@ -290,6 +293,7 @@ jobs: doas dmesg" - name: Insert ipoe kernel module + if: ${{ always() }} run: > ssh -i ssh-key -p2222 alpine@localhost "cd accel-ppp && doas insmod build/drivers/ipoe/driver/ipoe.ko && @@ -308,12 +312,14 @@ jobs: doas dmesg" - name: Insert vlan_mon kernel module + if: ${{ always() }} run: > ssh -i ssh-key -p2222 alpine@localhost "cd accel-ppp && doas insmod build/drivers/vlan_mon/driver/vlan_mon.ko && lsmod | grep vlan_mon" - name: Insert ppposeq kernel module + if: ${{ always() }} run: > ssh -i ssh-key -p2222 alpine@localhost "cd accel-ppp && doas modprobe pppox && doas insmod build/drivers/ppposeq/driver/ppposeq.ko && diff --git a/drivers/ipoe/ipoe.c b/drivers/ipoe/ipoe.c index a9b11fa2..251fec96 100644 --- a/drivers/ipoe/ipoe.c +++ b/drivers/ipoe/ipoe.c @@ -22,6 +22,7 @@ #include #include +#include #include #include #include -- cgit v1.2.3 From 60702f40ac55d474ce26bd8e1d904feb9e5f160f Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sat, 8 Aug 2026 19:19:13 +0300 Subject: ci: survive the negative branch counters gcov reports The coverage job fails while gcovr reads the data for triton.c: Unrecognized GCOV output ... branch 2 taken -1 NegativeHits: Got negative hit value in gcov line 'branch 2 taken -1' gcov emits those now and then, it is gcc bug 68080 and not something the source can avoid. gcovr 6 and later treat it as a fatal parse error unless --gcov-ignore-parse-errors names the case to tolerate. Passing that option unconditionally is not enough, since the job runs on both ubuntu-24.04 and ubuntu-22.04 and the gcovr in the latter predates it and takes no value. Ask gcovr whether it knows the option before adding it, and print what was decided so the log says which one ran. --- .github/workflows/run-tests.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 10f238cc..7ce55b6e 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -484,9 +484,17 @@ jobs: - name: Generate coverage reports (default(txt), csv, html) run: | mkdir -p tests/report - gcovr --config=tests/gcovr.conf --output=tests/report/accel-ppp.txt - gcovr --config=tests/gcovr.conf --csv --output=tests/report/accel-ppp.csv - gcovr --config=tests/gcovr.conf --html --html-details --output=tests/report/accel-ppp.html + # gcov reports negative branch counters now and then (gcc bug 68080). + # gcovr 6 and later stop with a parse error unless told to carry on, + # older ones do not know the option at all, so ask before using it. + IGNORE="" + if gcovr --help 2>&1 | grep -q negative_hits; then + IGNORE="--gcov-ignore-parse-errors=negative_hits.warn_once_per_file" + fi + echo "gcovr `gcovr --version | head -1`, extra options: '$IGNORE'" + gcovr --config=tests/gcovr.conf $IGNORE --output=tests/report/accel-ppp.txt + gcovr --config=tests/gcovr.conf $IGNORE --csv --output=tests/report/accel-ppp.csv + gcovr --config=tests/gcovr.conf $IGNORE --html --html-details --output=tests/report/accel-ppp.html - name: Show default coverage report run: cat tests/report/accel-ppp.txt -- cgit v1.2.3 From fc465210fb77af7d6a12780f3f256d17be2ce480 Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sat, 8 Aug 2026 23:17:46 +0300 Subject: fixup! ipoe: flush sessions left by a previous instance with a single command --- accel-pppd/ctrl/ipoe/ipoe_netlink.c | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/accel-pppd/ctrl/ipoe/ipoe_netlink.c b/accel-pppd/ctrl/ipoe/ipoe_netlink.c index 6eee2dca..d611c5f9 100644 --- a/accel-pppd/ctrl/ipoe/ipoe_netlink.c +++ b/accel-pppd/ctrl/ipoe/ipoe_netlink.c @@ -465,8 +465,7 @@ int ipoe_nl_flush_sessions(void) if (rtnl_open_byproto(&rth, 0, NETLINK_GENERIC)) { log_error("ipoe: cannot open generic netlink socket\n"); - errno = EIO; - return -1; + return -EIO; } memset(&req, 0, sizeof(req)); @@ -480,7 +479,7 @@ int ipoe_nl_flush_sessions(void) ghdr->cmd = IPOE_CMD_FLUSH; if (rtnl_talk(&rth, nlh, 0, 0, nlh, NULL, NULL, 0) < 0) - ret = -1; + ret = errno ? -errno : -EIO; rtnl_close(&rth); @@ -540,10 +539,12 @@ static void delete_sessions() static void flush_sessions() { - if (!ipoe_nl_flush_sessions()) + int r = ipoe_nl_flush_sessions(); + + if (!r) return; - if (errno == EOPNOTSUPP) { + if (r == -EOPNOTSUPP) { log_warn("ipoe: loaded ipoe module does not support IPOE_CMD_FLUSH," " removing sessions one by one, reload the module to fix\n"); delete_sessions(); @@ -551,7 +552,7 @@ static void flush_sessions() } log_error("ipoe: failed to remove sessions left by a previous instance:" - " %s\n", strerror(errno)); + " %s\n", strerror(-r)); } static void ipoe_up_handler(const struct sockaddr_nl *addr, struct nlmsghdr *h) -- cgit v1.2.3 From 811f87f071659410f9126d51b6ef72a4c64a02a9 Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sat, 8 Aug 2026 23:57:09 +0300 Subject: fixup! ipoe: flush sessions left by a previous instance with a single command --- accel-pppd/ctrl/ipoe/ipoe_netlink.c | 3 --- 1 file changed, 3 deletions(-) diff --git a/accel-pppd/ctrl/ipoe/ipoe_netlink.c b/accel-pppd/ctrl/ipoe/ipoe_netlink.c index d611c5f9..e0f41cdf 100644 --- a/accel-pppd/ctrl/ipoe/ipoe_netlink.c +++ b/accel-pppd/ctrl/ipoe/ipoe_netlink.c @@ -718,7 +718,6 @@ static void init(void) if (!ipoe_genl_id) { log_error("ipoe: cannot resolve netlink family, state left by a" " previous instance is not removed\n"); - rth.fd = -1; return; } @@ -733,13 +732,11 @@ static void init(void) if (mcg_id == -1) { log_warn("ipoe: unclassified packet handling is disabled\n"); - rth.fd = -1; return; } if (rtnl_open_byproto(&rth, 1 << (mcg_id - 1), NETLINK_GENERIC)) { log_error("ipoe: cannot open generic netlink socket\n"); - rth.fd = -1; return; } -- cgit v1.2.3 From 2b46f1c69ca77efc317673b080d4773db09a4a3e Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Sun, 9 Aug 2026 00:18:35 +0300 Subject: fixup! ipoe: flush sessions left by a previous instance with a single command --- accel-pppd/ctrl/ipoe/ipoe_netlink.c | 1 + 1 file changed, 1 insertion(+) diff --git a/accel-pppd/ctrl/ipoe/ipoe_netlink.c b/accel-pppd/ctrl/ipoe/ipoe_netlink.c index e0f41cdf..df4dec67 100644 --- a/accel-pppd/ctrl/ipoe/ipoe_netlink.c +++ b/accel-pppd/ctrl/ipoe/ipoe_netlink.c @@ -478,6 +478,7 @@ int ipoe_nl_flush_sessions(void) ghdr = NLMSG_DATA(&req.n); ghdr->cmd = IPOE_CMD_FLUSH; + errno = 0; if (rtnl_talk(&rth, nlh, 0, 0, nlh, NULL, NULL, 0) < 0) ret = errno ? -errno : -EIO; -- cgit v1.2.3