diff mbox series

[v3,2/6] ctrl_iface: MLO: introduce MLD level socket

Message ID 20240801165143.3212598-3-quic_adisi@quicinc.com
State Changes Requested
Headers show
Series MLO control socket changes | expand

Commit Message

Aditya Kumar Singh Aug. 1, 2024, 4:51 p.m. UTC
With MLO, each link have socket created with "<ifname>_link<link id>" under
the control interface directory.

Introduce a MLD level socket - "<ifname>" as well under the same control
interface directory. This socket can be used to pass the command to its
partner links directly instead of using the link level socket. Link ID
needs to be passed with the command.

The structure of the command is -
 "<COMMAND APPLICABALE FOR THE LINK> LINKID <link id>"

Directory looks something like this -
  $ ls /var/run/hostapd/
    wlan0
    wlan0_link0
    wlan0_link1

wlan0 here is the MLD level socket. Rest are each link level.

This would also help to maintain backwards compatibility with applications
which looks for <ifname> under the control interface directory.`

Signed-off-by: Aditya Kumar Singh <quic_adisi@quicinc.com>
---
 hostapd/ctrl_iface.c | 335 +++++++++++++++++++++++++++++++++++++++++++
 hostapd/ctrl_iface.h |   4 +
 hostapd/main.c       |   5 +
 src/ap/hostapd.c     |  11 ++
 src/ap/hostapd.h     |   6 +
 5 files changed, 361 insertions(+)

Comments

Jouni Malinen Aug. 5, 2024, 5:25 p.m. UTC | #1
On Thu, Aug 01, 2024 at 10:21:39PM +0530, Aditya Kumar Singh wrote:
> With MLO, each link have socket created with "<ifname>_link<link id>" under
> the control interface directory.
> 
> Introduce a MLD level socket - "<ifname>" as well under the same control
> interface directory. This socket can be used to pass the command to its
> partner links directly instead of using the link level socket. Link ID
> needs to be passed with the command.
> 
> The structure of the command is -
>  "<COMMAND APPLICABALE FOR THE LINK> LINKID <link id>"

That feels quite problematic since "LINKID" could occur in a valid
command, e.g., when setting a string parameter. This "LINKID <link id>"
part would likely need to be a prefix instead of postfix for the command
to make this more robust.

> diff --git a/hostapd/ctrl_iface.c b/hostapd/ctrl_iface.c

> +static int hostapd_mld_ctrl_iface_receive_process(struct hostapd_mld *mld,

> +	/* Check if link id is provided in the command or not */
> +	link_cmd = os_strstr(buf, "LINKID");
> +	if (link_cmd) {
> +		/* Trim the link id part now */
> +		*(link_cmd - 1) = '\0';

That needs bounds checking.. The received command could start with
"LINKID" and that -1 would make this write before the start of the
buffer. Though, my comment above will likely make this not applicable,
but anyway, all commands received from the control interface needs to be
fully verified to be valid to avoid security issues.

> +	} else if (os_strcmp(buf, "ATTACH") == 0) {
> +		if (hostapd_mld_ctrl_iface_attach(mld, from, fromlen, NULL))
> +			reply_len = -1;
> +	} else if (os_strncmp(buf, "ATTACH ", 7) == 0) {
> +		if (hostapd_mld_ctrl_iface_attach(mld, from, fromlen, buf + 7))
> +			reply_len = -1;
> +	} else if (os_strcmp(buf, "DETACH") == 0) {
> +		if (hostapd_mld_ctrl_iface_detach(mld, from, fromlen))
> +			reply_len = -1;

Is there a plan to use those hostapd_mld_ctrl_iface_{attach,detach}()
functions for something else than for wrapping a call to
ctrl_iface_{attach,detach}()? I would simply call the existing functions
directly instead of going through that minimal wrapper function.

> +static void hostapd_mld_ctrl_iface_receive(int sock, void *eloop_ctx,

> +	os_snprintf(buf, len, "%s/%s", mld->ctrl_interface, mld->name);
> +	buf[len - 1] = '\0';

Instead of hardcoding \0 termination, it would be better to explicitly
check the os_snprintf() return value with os_snprintf_error() to catch
all truncation cases.
Aditya Kumar Singh Aug. 6, 2024, 5:28 a.m. UTC | #2
On 8/5/24 22:55, Jouni Malinen wrote:
> On Thu, Aug 01, 2024 at 10:21:39PM +0530, Aditya Kumar Singh wrote:
>> With MLO, each link have socket created with "<ifname>_link<link id>" under
>> the control interface directory.
>>
>> Introduce a MLD level socket - "<ifname>" as well under the same control
>> interface directory. This socket can be used to pass the command to its
>> partner links directly instead of using the link level socket. Link ID
>> needs to be passed with the command.
>>
>> The structure of the command is -
>>   "<COMMAND APPLICABALE FOR THE LINK> LINKID <link id>"
> 
> That feels quite problematic since "LINKID" could occur in a valid
> command, e.g., when setting a string parameter. This "LINKID <link id>"
> part would likely need to be a prefix instead of postfix for the command
> to make this more robust.
> 

Sure will move to prefix. Thanks for the suggestion.


>> diff --git a/hostapd/ctrl_iface.c b/hostapd/ctrl_iface.c
> 
>> +static int hostapd_mld_ctrl_iface_receive_process(struct hostapd_mld *mld,
> 
>> +	/* Check if link id is provided in the command or not */
>> +	link_cmd = os_strstr(buf, "LINKID");
>> +	if (link_cmd) {
>> +		/* Trim the link id part now */
>> +		*(link_cmd - 1) = '\0';
> 
> That needs bounds checking.. The received command could start with
> "LINKID" and that -1 would make this write before the start of the
> buffer. Though, my comment above will likely make this not applicable,
> but anyway, all commands received from the control interface needs to be
> fully verified to be valid to avoid security issues.

Sure got it.

> 
>> +	} else if (os_strcmp(buf, "ATTACH") == 0) {
>> +		if (hostapd_mld_ctrl_iface_attach(mld, from, fromlen, NULL))
>> +			reply_len = -1;
>> +	} else if (os_strncmp(buf, "ATTACH ", 7) == 0) {
>> +		if (hostapd_mld_ctrl_iface_attach(mld, from, fromlen, buf + 7))
>> +			reply_len = -1;
>> +	} else if (os_strcmp(buf, "DETACH") == 0) {
>> +		if (hostapd_mld_ctrl_iface_detach(mld, from, fromlen))
>> +			reply_len = -1;
> 
> Is there a plan to use those hostapd_mld_ctrl_iface_{attach,detach}()
> functions for something else than for wrapping a call to
> ctrl_iface_{attach,detach}()? I would simply call the existing functions
> directly instead of going through that minimal wrapper function.
> 

I don't have any plans as of now. Sure, I will directly call the 
functions instead of the wrapper.


>> +static void hostapd_mld_ctrl_iface_receive(int sock, void *eloop_ctx,
> 
>> +	os_snprintf(buf, len, "%s/%s", mld->ctrl_interface, mld->name);
>> +	buf[len - 1] = '\0';
> 
> Instead of hardcoding \0 termination, it would be better to explicitly
> check the os_snprintf() return value with os_snprintf_error() to catch
> all truncation cases.

Sure will do.
Aditya Kumar Singh Aug. 6, 2024, 6:17 a.m. UTC | #3
On 8/5/24 22:55, Jouni Malinen wrote:
> Is there a plan to use those hostapd_mld_ctrl_iface_{attach,detach}()
> functions for something else than for wrapping a call to
> ctrl_iface_{attach,detach}()? I would simply call the existing functions
> directly instead of going through that minimal wrapper function.

Now that I see, this was done in order to have similar naming link for 
non-AP MLD. Please see hostapd_ctrl_iface_attach(). So similarly, for 
MLD as well the wrapper is introduced. Do you still want to remove the 
wrapper?
Jouni Malinen Aug. 12, 2024, 7:04 p.m. UTC | #4
On Tue, Aug 06, 2024 at 11:47:27AM +0530, Aditya Kumar Singh wrote:
> On 8/5/24 22:55, Jouni Malinen wrote:
> > Is there a plan to use those hostapd_mld_ctrl_iface_{attach,detach}()
> > functions for something else than for wrapping a call to
> > ctrl_iface_{attach,detach}()? I would simply call the existing functions
> > directly instead of going through that minimal wrapper function.
> 
> Now that I see, this was done in order to have similar naming link for
> non-AP MLD. Please see hostapd_ctrl_iface_attach(). So similarly, for MLD as
> well the wrapper is introduced. Do you still want to remove the wrapper?

hostapd_ctrl_iface_attach() used to contain the implementation and that
ended up being a minimal wrapper when the function was shared for
multiple purposes in commit 89b781bc89ef ("hostapd: Use common functions
for ctrl_iface"). It was never cleaned up, but I guess better late than
never.. So yes, I think the one-liner wrapper should be removed if there
is no plan on extending it (and independently of that, the existing
one-liner wrappers from that commit could be removed).
Aditya Kumar Singh Aug. 13, 2024, 4:11 a.m. UTC | #5
On 8/13/24 00:34, Jouni Malinen wrote:
> On Tue, Aug 06, 2024 at 11:47:27AM +0530, Aditya Kumar Singh wrote:
>> On 8/5/24 22:55, Jouni Malinen wrote:
>>> Is there a plan to use those hostapd_mld_ctrl_iface_{attach,detach}()
>>> functions for something else than for wrapping a call to
>>> ctrl_iface_{attach,detach}()? I would simply call the existing functions
>>> directly instead of going through that minimal wrapper function.
>>
>> Now that I see, this was done in order to have similar naming link for
>> non-AP MLD. Please see hostapd_ctrl_iface_attach(). So similarly, for MLD as
>> well the wrapper is introduced. Do you still want to remove the wrapper?
> 
> hostapd_ctrl_iface_attach() used to contain the implementation and that
> ended up being a minimal wrapper when the function was shared for
> multiple purposes in commit 89b781bc89ef ("hostapd: Use common functions
> for ctrl_iface"). It was never cleaned up, but I guess better late than
> never.. So yes, I think the one-liner wrapper should be removed if there
> is no plan on extending it (and independently of that, the existing
> one-liner wrappers from that commit could be removed).
>   

:) Sure, got it, will do as suggested.
diff mbox series

Patch

diff --git a/hostapd/ctrl_iface.c b/hostapd/ctrl_iface.c
index 3fa33be7a894..5fe29147fa38 100644
--- a/hostapd/ctrl_iface.c
+++ b/hostapd/ctrl_iface.c
@@ -4682,6 +4682,341 @@  done:
 }
 
 
+#ifdef CONFIG_IEEE80211BE
+#ifndef CONFIG_CTRL_IFACE_UDP
+static int hostapd_mld_ctrl_iface_attach(struct hostapd_mld *mld,
+					 struct sockaddr_storage *from,
+					 socklen_t fromlen, const char *input)
+{
+	return ctrl_iface_attach(&mld->ctrl_dst, from, fromlen, input);
+}
+
+
+static int hostapd_mld_ctrl_iface_detach(struct hostapd_mld *mld,
+					 struct sockaddr_storage *from,
+					 socklen_t fromlen)
+{
+	return ctrl_iface_detach(&mld->ctrl_dst, from, fromlen);
+}
+
+
+static int hostapd_mld_ctrl_iface_receive_process(struct hostapd_mld *mld,
+						  char *buf, char *reply,
+						  int reply_size,
+						  struct sockaddr_storage *from,
+						  socklen_t fromlen)
+{
+	struct hostapd_data *link_hapd, *link_itr;
+	int reply_len, link_id = -1;
+	char *link_cmd;
+	bool found = false;
+
+	os_memcpy(reply, "OK\n", 3);
+	reply_len = 3;
+
+	/* Check if link id is provided in the command or not */
+	link_cmd = os_strstr(buf, "LINKID");
+	if (link_cmd) {
+		/* Trim the link id part now */
+		*(link_cmd - 1) = '\0';
+
+		link_cmd += 7;
+		link_id = atoi(link_cmd);
+
+		if (link_id < 0 || link_id >= 15) {
+			os_memcpy(reply, "INVALID LINK ID\n", 16);
+			reply_len = 16;
+			return reply_len;
+		}
+
+		link_hapd = mld->fbss;
+		if (!link_hapd) {
+			os_memcpy(reply, "NO LINKS ACTIVE\n", 16);
+			reply_len = 16;
+			return reply_len;
+		}
+
+		for_each_mld_link(link_itr, link_hapd) {
+			if (link_itr->mld_link_id == link_id) {
+				found = true;
+				break;
+			}
+		}
+
+		if (!found) {
+			os_memcpy(reply, "FAIL\n", 5);
+			reply_len = 5;
+			return reply_len;
+		}
+
+		link_hapd = link_itr;
+	} else {
+		link_hapd = mld->fbss;
+	}
+
+	if (os_strcmp(buf, "PING") == 0) {
+		os_memcpy(reply, "PONG\n", 5);
+		reply_len = 5;
+	} else if (os_strcmp(buf, "ATTACH") == 0) {
+		if (hostapd_mld_ctrl_iface_attach(mld, from, fromlen, NULL))
+			reply_len = -1;
+	} else if (os_strncmp(buf, "ATTACH ", 7) == 0) {
+		if (hostapd_mld_ctrl_iface_attach(mld, from, fromlen, buf + 7))
+			reply_len = -1;
+	} else if (os_strcmp(buf, "DETACH") == 0) {
+		if (hostapd_mld_ctrl_iface_detach(mld, from, fromlen))
+			reply_len = -1;
+	} else {
+		if (link_id == -1)
+			wpa_printf(MSG_DEBUG, "Link ID not provided, using first link BSS (if available)");
+
+		if (!link_hapd)
+			reply_len = -1;
+		else
+			reply_len =
+				hostapd_ctrl_iface_receive_process(link_hapd, buf,
+								   reply, reply_size,
+								   from, fromlen);
+	}
+
+	if (reply_len < 0) {
+		os_memcpy(reply, "FAIL\n", 5);
+		reply_len = 5;
+	}
+
+	return reply_len;
+}
+
+
+static void hostapd_mld_ctrl_iface_receive(int sock, void *eloop_ctx,
+					   void *sock_ctx)
+{
+	struct hostapd_mld *mld = eloop_ctx;
+	char buf[4096];
+	int res;
+	struct sockaddr_storage from;
+	socklen_t fromlen = sizeof(from);
+	char *reply, *pos = buf;
+	const int reply_size = 4096;
+	int reply_len;
+	int level = MSG_DEBUG;
+
+	res = recvfrom(sock, buf, sizeof(buf) - 1, 0,
+		       (struct sockaddr *) &from, &fromlen);
+	if (res < 0) {
+		wpa_printf(MSG_ERROR, "recvfrom(mld ctrl_iface): %s",
+			   strerror(errno));
+		return;
+	}
+	buf[res] = '\0';
+
+	reply = os_malloc(reply_size);
+	if (reply == NULL) {
+		if (sendto(sock, "FAIL\n", 5, 0, (struct sockaddr *) &from,
+			   fromlen) < 0) {
+			wpa_printf(MSG_DEBUG, "MLD CTRL: sendto failed: %s",
+				   strerror(errno));
+		}
+		return;
+	}
+
+	if (os_strcmp(pos, "PING") == 0)
+		level = MSG_EXCESSIVE;
+
+	wpa_hexdump_ascii(level, "RX MLD ctrl_iface", pos, res);
+
+	reply_len = hostapd_mld_ctrl_iface_receive_process(mld, pos,
+							   reply, reply_size,
+							   &from, fromlen);
+
+	if (sendto(sock, reply, reply_len, 0, (struct sockaddr *) &from,
+		   fromlen) < 0) {
+		wpa_printf(MSG_DEBUG, "MLD CTRL: sendto failed: %s",
+			   strerror(errno));
+	}
+	os_free(reply);
+}
+
+
+static char * hostapd_mld_ctrl_iface_path(struct hostapd_mld *mld)
+{
+	char *buf;
+	size_t len;
+
+	if (!mld->ctrl_interface)
+		return NULL;
+
+	len = os_strlen(mld->ctrl_interface) + os_strlen(mld->name) + 2;
+
+	buf = os_malloc(len);
+	if (buf == NULL)
+		return NULL;
+
+	os_snprintf(buf, len, "%s/%s", mld->ctrl_interface, mld->name);
+	buf[len - 1] = '\0';
+	return buf;
+}
+#endif /* !CONFIG_CTRL_IFACE_UDP */
+
+
+int hostapd_mld_ctrl_iface_init(struct hostapd_mld *mld)
+{
+#ifndef CONFIG_CTRL_IFACE_UDP
+	struct sockaddr_un addr;
+	int s = -1;
+	char *fname = NULL;
+
+	if (!mld)
+		return -1;
+
+	if (mld->ctrl_sock > -1) {
+		wpa_printf(MSG_DEBUG, "MLD %s ctrl_iface already exists!",
+			   mld->name);
+		return 0;
+	}
+
+	dl_list_init(&mld->ctrl_dst);
+
+	if (mld->ctrl_interface == NULL)
+		return 0;
+
+	if (mkdir(mld->ctrl_interface, S_IRWXU | S_IRWXG) < 0) {
+		if (errno == EEXIST) {
+			wpa_printf(MSG_DEBUG, "Using existing control "
+				   "interface directory.");
+		} else {
+			wpa_printf(MSG_ERROR, "mkdir[ctrl_interface]: %s",
+				   strerror(errno));
+			goto fail;
+		}
+	}
+
+	if (os_strlen(mld->ctrl_interface) + 1 +
+	    os_strlen(mld->name) >= sizeof(addr.sun_path))
+		goto fail;
+
+	s = socket(PF_UNIX, SOCK_DGRAM, 0);
+	if (s < 0) {
+		wpa_printf(MSG_ERROR, "socket(PF_UNIX): %s", strerror(errno));
+		goto fail;
+	}
+
+	os_memset(&addr, 0, sizeof(addr));
+#ifdef __FreeBSD__
+	addr.sun_len = sizeof(addr);
+#endif /* __FreeBSD__ */
+	addr.sun_family = AF_UNIX;
+
+	fname = hostapd_mld_ctrl_iface_path(mld);
+	if (fname == NULL)
+		goto fail;
+
+	os_strlcpy(addr.sun_path, fname, sizeof(addr.sun_path));
+
+	wpa_printf(MSG_DEBUG, "Setting up MLD %s ctrl_iface", mld->name);
+
+	if (bind(s, (struct sockaddr *) &addr, sizeof(addr)) < 0) {
+		wpa_printf(MSG_DEBUG, "ctrl_iface bind(PF_UNIX) failed: %s",
+			   strerror(errno));
+		if (connect(s, (struct sockaddr *) &addr, sizeof(addr)) < 0) {
+			wpa_printf(MSG_DEBUG, "ctrl_iface exists, but does not"
+				   " allow connections - assuming it was left"
+				   "over from forced program termination");
+			if (unlink(fname) < 0) {
+				wpa_printf(MSG_ERROR,
+					   "Could not unlink existing ctrl_iface socket '%s': %s",
+					   fname, strerror(errno));
+				goto fail;
+			}
+			if (bind(s, (struct sockaddr *) &addr, sizeof(addr)) <
+			    0) {
+				wpa_printf(MSG_ERROR,
+					   "hostapd-ctrl-iface: bind(PF_UNIX): %s",
+					   strerror(errno));
+				goto fail;
+			}
+			wpa_printf(MSG_DEBUG, "Successfully replaced leftover "
+				   "ctrl_iface socket '%s'", fname);
+		} else {
+			wpa_printf(MSG_INFO, "ctrl_iface exists and seems to "
+				   "be in use - cannot override it");
+			wpa_printf(MSG_INFO, "Delete '%s' manually if it is "
+				   "not used anymore", fname);
+			os_free(fname);
+			fname = NULL;
+			goto fail;
+		}
+	}
+
+	if (chmod(fname, S_IRWXU | S_IRWXG) < 0) {
+		wpa_printf(MSG_ERROR, "chmod[ctrl_interface/ifname]: %s",
+			   strerror(errno));
+		goto fail;
+	}
+	os_free(fname);
+
+	mld->ctrl_sock = s;
+
+	if (eloop_register_read_sock(s, hostapd_mld_ctrl_iface_receive, mld,
+				     NULL) < 0)
+		return -1;
+
+	return 0;
+
+fail:
+	if (s >= 0)
+		close(s);
+	if (fname) {
+		unlink(fname);
+		os_free(fname);
+	}
+	return -1;
+#endif /* !CONFIG_CTRL_IFACE_UDP */
+	return 0;
+}
+
+
+void hostapd_mld_ctrl_iface_deinit(struct hostapd_mld *mld)
+{
+#ifndef CONFIG_CTRL_IFACE_UDP
+	struct wpa_ctrl_dst *dst, *prev;
+
+	if (mld->ctrl_sock > -1) {
+		char *fname;
+		eloop_unregister_read_sock(mld->ctrl_sock);
+		close(mld->ctrl_sock);
+		mld->ctrl_sock = -1;
+
+		fname = hostapd_mld_ctrl_iface_path(mld);
+		if (fname)
+			unlink(fname);
+		os_free(fname);
+
+		if (mld->ctrl_interface &&
+		    rmdir(mld->ctrl_interface) < 0) {
+			if (errno == ENOTEMPTY) {
+				wpa_printf(MSG_DEBUG, "MLD Control interface "
+					   "directory not empty - leaving it "
+					   "behind");
+			} else {
+				wpa_printf(MSG_ERROR,
+					   "rmdir[ctrl_interface=%s]: %s",
+					   mld->ctrl_interface,
+					   strerror(errno));
+			}
+		}
+	}
+
+	dl_list_for_each_safe(dst, prev, &mld->ctrl_dst, struct wpa_ctrl_dst,
+			      list)
+		os_free(dst);
+#endif /* !CONFIG_CTRL_IFACE_UDP */
+
+	os_free(mld->ctrl_interface);
+}
+#endif /* CONFIG_IEEE80211BE */
+
+
 #ifndef CONFIG_CTRL_IFACE_UDP
 static char * hostapd_ctrl_iface_path(struct hostapd_data *hapd)
 {
diff --git a/hostapd/ctrl_iface.h b/hostapd/ctrl_iface.h
index 3341a66bdc6c..ec5a95be785c 100644
--- a/hostapd/ctrl_iface.h
+++ b/hostapd/ctrl_iface.h
@@ -14,6 +14,10 @@  int hostapd_ctrl_iface_init(struct hostapd_data *hapd);
 void hostapd_ctrl_iface_deinit(struct hostapd_data *hapd);
 int hostapd_global_ctrl_iface_init(struct hapd_interfaces *interface);
 void hostapd_global_ctrl_iface_deinit(struct hapd_interfaces *interface);
+#ifdef CONFIG_IEEE80211BE
+int hostapd_mld_ctrl_iface_init(struct hostapd_mld *mld);
+void hostapd_mld_ctrl_iface_deinit(struct hostapd_mld *mld);
+#endif /* CONFIG_IEEE80211BE */
 #else /* CONFIG_NO_CTRL_IFACE */
 static inline int hostapd_ctrl_iface_init(struct hostapd_data *hapd)
 {
diff --git a/hostapd/main.c b/hostapd/main.c
index 00e02bb034c9..aa1f69812fd8 100644
--- a/hostapd/main.c
+++ b/hostapd/main.c
@@ -748,6 +748,7 @@  static void hostapd_global_cleanup_mld(struct hapd_interfaces *interfaces)
 		if (!interfaces->mld[i])
 			continue;
 
+		interfaces->mld_ctrl_iface_deinit(interfaces->mld[i]);
 		os_free(interfaces->mld[i]);
 		interfaces->mld[i] = NULL;
 	}
@@ -793,6 +794,10 @@  int main(int argc, char *argv[])
 	interfaces.global_iface_path = NULL;
 	interfaces.global_iface_name = NULL;
 	interfaces.global_ctrl_sock = -1;
+#ifdef CONFIG_IEEE80211BE
+	interfaces.mld_ctrl_iface_init = hostapd_mld_ctrl_iface_init;
+	interfaces.mld_ctrl_iface_deinit = hostapd_mld_ctrl_iface_deinit;
+#endif /* CONFIG_IEEE80211BE */
 	dl_list_init(&interfaces.global_ctrl_dst);
 #ifdef CONFIG_ETH_P_OUI
 	dl_list_init(&interfaces.eth_p_oui);
diff --git a/src/ap/hostapd.c b/src/ap/hostapd.c
index 49c9d0ddefd7..2794bb14efdf 100644
--- a/src/ap/hostapd.c
+++ b/src/ap/hostapd.c
@@ -3095,9 +3095,18 @@  static void hostapd_bss_setup_multi_link(struct hostapd_data *hapd,
 
 	os_strlcpy(mld->name, conf->iface, sizeof(conf->iface));
 	dl_list_init(&mld->links);
+	mld->ctrl_sock = -1;
+	mld->ctrl_interface = os_strdup(hapd->conf->ctrl_interface);
 
 	wpa_printf(MSG_DEBUG, "AP MLD %s created", mld->name);
 
+	/*
+	 * Initialize MLD control interfaces early to allow external monitoring of
+	 * link setup operations.
+	 */
+	if (interfaces->mld_ctrl_iface_init(mld))
+		goto fail;
+
 	hapd->mld = mld;
 	hostapd_mld_ref_inc(mld);
 	hostapd_bss_alloc_link_id(hapd);
@@ -3157,6 +3166,8 @@  static void hostapd_cleanup_unused_mlds(struct hapd_interfaces *interfaces)
 		if (!remove && !forced_remove)
 			continue;
 
+		interfaces->mld_ctrl_iface_deinit(mld);
+
 		wpa_printf(MSG_DEBUG, "AP MLD %s: Freed%s", mld->name,
 			   forced_remove ? " (forced)" : "");
 		os_free(mld);
diff --git a/src/ap/hostapd.h b/src/ap/hostapd.h
index 34a665562d35..2ef63e5f2383 100644
--- a/src/ap/hostapd.h
+++ b/src/ap/hostapd.h
@@ -97,6 +97,8 @@  struct hapd_interfaces {
 #ifdef CONFIG_IEEE80211BE
 	struct hostapd_mld **mld;
 	size_t mld_count;
+	int (*mld_ctrl_iface_init)(struct hostapd_mld *mld);
+	void (*mld_ctrl_iface_deinit)(struct hostapd_mld *mld);
 #endif /* CONFIG_IEEE80211BE */
 };
 
@@ -519,6 +521,10 @@  struct hostapd_mld {
 
 	struct hostapd_data *fbss;
 	struct dl_list links; /* List head of all affiliated links */
+
+	int ctrl_sock;
+	struct dl_list ctrl_dst;
+	char *ctrl_interface; /* directory for UNIX domain sockets */
 };
 
 #define HOSTAPD_MLD_MAX_REF_COUNT      0xFF