commit 487ddce0c04e1e9401bfedd97821e8a70c516756
parent d56f938937aa14a04d88f755400c2bbccecd8aa8
Author: finwo <finwo@pm.me>
Date: Thu, 24 Sep 2026 20:18:15 +0200
Convert to stdio plugins instead of C ABI
Diffstat:
35 files changed, 2631 insertions(+), 1102 deletions(-)
diff --git a/README.md b/README.md
@@ -27,7 +27,8 @@ retry. Netlink acks before we reply, so callers are synchronous by
construction. `tests/unit/test_netlink.sh` enforces this.
**No libnl or libmnl.** `src/netlink/netlink.h` talks to the kernel directly.
-The only link-time dependency is `-ldl`, for `dlopen`ing dataplane plugins.
+There are no link-time dependencies: plugins are separate processes, so
+nothing is dlopen'd.
**Accepted IPC connections are blocking.** The listener is non-blocking so it
can sit in `poll()`, but the accepted connection must not be: a non-blocking
@@ -97,16 +98,109 @@ because a file moved is indistinguishable from one that matches nothing because
the code is correct, and only the second should pass.
+Configuration
+-------------
+
+`/etc/linkd.cnf`, same grammar as the rest:
+
+ config_ports /etc/network/ports /etc/network/ports.d/*.cnf
+ config_iface /etc/network/interfaces /etc/network/interfaces.d/*.cnf
+ listen unix:///var/run/linkd.sock
+ listen tcp://127.0.0.1:6789
+ authfile /etc/linkd.passwd
+ plugin /usr/lib/linkd/bcm
+
+A missing file is not an error; the defaults above are what you get. Patterns
+are expanded by `source`, so one matching nothing is fine -- a system with no
+drop-ins is normal.
+
+
+Clients and authorization
+-------------------------
+
+The daemon speaks RESP on every address it is told to `listen` on, so
+`redis-cli` works as a client. `linkctl` sends whatever you give it:
+
+ linkctl PING
+ linkctl COMMAND
+ linkctl IFQUERY lo
+
+`ifup`, `ifdown`, `ifquery` and `ifreload` are symlinks that send the
+corresponding command.
+
+A connection has one of three roles, and every command names the minimum it
+needs:
+
+| Role | Commands |
+| ---------- | --------------------------------------------- |
+| *none* | `PING`, `AUTH`, `QUIT` |
+| `readonly` | + `IFQUERY`, `INFO`, `COMMAND` |
+| `full` | + `IFUP`, `IFDOWN`, `IFRELOAD` |
+
+**A root peer on a unix socket starts with `full`**: the kernel vouches for the
+uid via `SO_PEERCRED`, which beats any password. Everyone else starts at *none*
+and authenticates with `AUTH <user> <password>`.
+
+Credentials live in the file named by `authfile`:
+
+ admin:$pbkdf2-sha256$29000$<salt>$<hash>:full
+ watcher:$pbkdf2-sha256$29000$<salt>$<hash>:readonly
+
+Generate entries with `linkctl hash [user]`. The format is passlib's
+`pbkdf2_sha256`, adapted base64 and all, so entries made by passlib or Django
+work unchanged. A missing role field means `readonly`.
+
+The file is refused outright if it is world-writable, and warned about if it is
+readable beyond its owner. **Binding a `tcp://` listener without an `authfile`
+is a hard error**, so an unauthenticated network listener cannot be created by
+forgetting something.
+
+
Dataplane plugins
-----------------
-`src/dataplane/plugin.c` `dlopen`s shared objects from
-`/usr/lib/linkd/dataplane/` (`LINKD_DATAPLANE_DIR` in `src/dataplane.h`), each
-exporting a `struct dp_ops` matching `LINKD_DATAPLANE_ABI`. Without one, linkd
-uses the built-in kernel dataplane.
+A plugin is a **program**, not a shared library. linkd talks to it in RESP
+(the Redis wire protocol), so a plugin can be written in anything that can read
+and write a pipe. `tests/fixtures/plugin-echo.sh` is a working one in POSIX
+shell, and the test suite drives it, so that claim is tested rather than
+asserted.
+
+Plugins are declared in `/etc/linkd.cnf`:
+
+ plugin /usr/lib/linkd/bcm spawned, RESP over stdin/stdout
+ plugin tcp://user:pass@host:6789 connected to, AUTH if credentials
+ optional failures do not fail operations
+
+On startup linkd asks each plugin `COMMAND` for the verbs it implements and
+`INFO` for its identity. `INFO` must report `hardware_detected:0|1` in its
+`# Stats` section; a plugin answering `0` is refused, which is how a driver for
+absent hardware declines rather than failing later and opaquely.
+
+Operations are **broadcast** to every plugin advertising the verb, and all of
+them are called even if an earlier one fails, so plugins cannot end up
+disagreeing about what was applied:
+
+ PORT ADMIN <if> <up|down> RIF ADD|DEL <if> <cidr> table <n>
+ PORT MTU <if> <mtu> NEIGH ADD|DEL <if> <ip> <mac> table <n>
+ PORT APPLY <if> [speed N] [fec X] [autoneg 0|1]
+ ROUTE ADD|DEL <dst> [via <gw> dev <if>]... [metric N] table <n>
+ RESYNC
+
+Any `-ERR` from a plugin fails the operation unless that plugin is `optional`.
+
+A plugin may also own configuration. When the `plugin` stanza hits a
+sub-directive linkd does not recognise, it starts the plugin, asks `CONFIG
+LIST` for the directives it accepts, and forwards matching ones as
+`CONFIG SET <directive> <args...>`. A directive neither side claims ends the
+stanza and is re-dispatched normally.
+
+A plugin that dies or stops answering is restarted with backoff (0.5s
+doubling to 30s). While it is down its operations fail, so a switch never
+silently accepts configuration it is not programming into hardware.
-`plugins/bcm/` is the Broadcom XGS plugin. It is not built by the top-level
-Makefile and requires `SDK=` pointing at a built OpenBCM tree:
+`plugins/bcm/` is the Broadcom XGS plugin, currently a skeleton that answers
+`COMMAND`/`INFO` and errors on everything else. It is not built by the
+top-level Makefile and requires `SDK=` pointing at a built OpenBCM tree:
make -C plugins/bcm SDK=/path/to/opennsl
diff --git a/plugins/bcm/.dep b/plugins/bcm/.dep
@@ -0,0 +1 @@
+finwo/resp https://git.finwo.net/lib/resp.c/archives/heads/main.tar.gz
diff --git a/plugins/bcm/.gitignore b/plugins/bcm/.gitignore
@@ -0,0 +1,3 @@
+/lib/
+/bcm
+*.o
diff --git a/plugins/bcm/Makefile b/plugins/bcm/Makefile
@@ -1,27 +1,26 @@
-BIN=bcm.so
+BIN=bcm
# Path to a built OpenBCM SDK tree. Supplied by the openbcm package build;
# there is no sensible default, so the build fails loudly without it.
SDK?=
CFLAGS?=-Wall -Wextra -O2
-CFLAGS+=-fPIC
-# linkd's dataplane contract
INCLUDES:=
-INCLUDES+=-I ../../src
+INCLUDES+=-I src
LDFLAGS?=
-LDFLAGS+=-shared
ifneq ($(SDK),)
INCLUDES+=-I $(SDK)/include
LDFLAGS +=-L $(SDK)/build -lbcm
endif
+include lib/.dep/config.mk
+
CFLAGS+=$(INCLUDES)
-SRC:=$(wildcard src/*.c)
+SRC+=$(wildcard src/*.c)
OBJ:=$(SRC:.c=.o)
.PHONY: default
@@ -46,4 +45,4 @@ clean:
.PHONY: install
install: $(BIN)
- install -Dm0755 $(BIN) $(DESTDIR)/usr/lib/linkd/dataplane/$(BIN)
+ install -Dm0755 $(BIN) $(DESTDIR)/usr/lib/linkd/$(BIN)
diff --git a/plugins/bcm/src/dataplane.c b/plugins/bcm/src/dataplane.c
@@ -1,125 +0,0 @@
-// Broadcom XGS dataplane backend for linkd.
-//
-// Built and shipped by the `openbcm` package, NOT by the linkd build. It is a
-// plugin precisely so that a single OS image runs unchanged on hardware with
-// and without a switching ASIC: on a Broadcom box the openbcm package drops
-// this object into LINKD_DATAPLANE_DIR, everywhere else the directory stays
-// empty and linkd falls back to the built-in kernel backend.
-//
-// Building this requires the OpenBCM SDK headers and libbcm. See the README
-// next to this file.
-
-#include <stdio.h>
-#include <unistd.h>
-
-#include "dataplane.h"
-
-// The BDE character device is created by the openbcm kernel modules once they
-// have enumerated a switch ASIC over PCIe. Its presence is the cheapest
-// reliable signal that this backend can drive the box.
-#define BCM_BDE_DEV "/dev/linux-kernel-bde"
-
-static int bcm_probe(void) {
- if (access(BCM_BDE_DEV, F_OK) != 0) return DP_RET_ERROR;
- return DP_RET_OK;
-}
-
-static int bcm_init(void) {
- // TODO: SDK bring-up.
- // - read the board config (config.bcm) for the port/SerDes map
- // - soc_cm_device_create / attach unit 0
- // - run the board init sequence
- // - create KNET netdevs (swpN) for each front-panel port
- fprintf(stderr, "dataplane/bcm: SDK bring-up not implemented\n");
- return DP_RET_ERROR;
-}
-
-static void bcm_fini(void) {
- // TODO: detach unit, tear down KNET netdevs
-}
-
-static int bcm_port_apply(const struct dp_port *port) {
- // TODO: bcm_port_speed_set / bcm_port_autoneg_set, and
- // bcm_port_phy_control_set for FEC
- (void)port;
- return DP_RET_ERROR;
-}
-
-static int bcm_port_admin(const char *ifname, bool up) {
- // TODO: bcm_port_enable_set
- (void)ifname;
- (void)up;
- return DP_RET_ERROR;
-}
-
-static int bcm_port_mtu(const char *ifname, uint32_t mtu) {
- // TODO: bcm_port_frame_max_set. Driven by the netlink mirror so the ASIC
- // frame limit tracks whatever ifupdown put on the netdev.
- (void)ifname;
- (void)mtu;
- return DP_RET_ERROR;
-}
-
-static int bcm_rif_add(const struct dp_rif *rif) {
- // TODO: bcm_l3_intf_create, keyed by rif->table for per-VRF LPM
- (void)rif;
- return DP_RET_ERROR;
-}
-
-static int bcm_rif_del(const struct dp_rif *rif) {
- // TODO: bcm_l3_intf_delete, keyed by rif->table
- (void)rif;
- return DP_RET_ERROR;
-}
-
-static int bcm_neigh_add(const struct dp_neigh *neigh) {
- // TODO: bcm_l3_host_add, keyed by neigh->table
- (void)neigh;
- return DP_RET_ERROR;
-}
-
-static int bcm_neigh_del(const struct dp_neigh *neigh) {
- // TODO: bcm_l3_host_delete, keyed by neigh->table
- (void)neigh;
- return DP_RET_ERROR;
-}
-
-static int bcm_route_add(const struct dp_route *route) {
- // TODO: bcm_l3_route_add with vrf context from route->table; for nh_count > 1
- // build a bcm_l3_egress_ecmp group and refcount it per table across
- // routes sharing the same nexthop set
- (void)route;
- return DP_RET_ERROR;
-}
-
-static int bcm_route_del(const struct dp_route *route) {
- // TODO: bcm_l3_route_delete with vrf context from route->table, release ECMP
- // group when refcount hits zero
- (void)route;
- return DP_RET_ERROR;
-}
-
-static int bcm_resync(void) {
- // TODO: walk the ASIC tables, diff against the kernel, converge.
- // Netlink drops messages under load, so this is the correctness backstop,
- // not an optimisation.
- return DP_RET_ERROR;
-}
-
-struct dp_ops linkd_dataplane_ops = {
- .abi = LINKD_DATAPLANE_ABI,
- .name = "bcm",
- .probe = bcm_probe,
- .init = bcm_init,
- .fini = bcm_fini,
- .port_apply = bcm_port_apply,
- .port_admin = bcm_port_admin,
- .port_mtu = bcm_port_mtu,
- .rif_add = bcm_rif_add,
- .rif_del = bcm_rif_del,
- .neigh_add = bcm_neigh_add,
- .neigh_del = bcm_neigh_del,
- .route_add = bcm_route_add,
- .route_del = bcm_route_del,
- .resync = bcm_resync,
-};
diff --git a/plugins/bcm/src/main.c b/plugins/bcm/src/main.c
@@ -0,0 +1,111 @@
+// Broadcom XGS plugin for linkd: a RESP server on stdin/stdout.
+//
+// Skeleton. Every operation below is unimplemented and answers with an error,
+// which linkd surfaces rather than silently treating as applied. INFO reports
+// hardware_detected, which is how linkd decides whether to use this plugin at
+// all -- see the /dev/linux-kernel-bde probe in hardware_present().
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <unistd.h>
+
+#include "finwo/resp.h"
+
+static int hardware_present(void) {
+ // The legacy BDE character device. A box with the module loaded and a
+ // supported ASIC has it; anything else does not.
+ //
+ // Note /proc/devices on captured hardware also lists linux_ngbde (the newer
+ // BDE) alongside this one. Which of the two bcm.so binds to is still an
+ // open question, so this check may need widening.
+ return access("/dev/linux-kernel-bde", F_OK) == 0;
+}
+
+static void emit(resp_object *o) {
+ char *buf = NULL;
+ size_t len = 0;
+ if (resp_serialize(o, &buf, &len) == 0) {
+ fwrite(buf, 1, len, stdout);
+ fflush(stdout);
+ free(buf);
+ }
+ resp_free(o);
+}
+
+static void reply_ok(void) { emit(resp_simple_init("OK")); }
+static void reply_err(const char *msg) { emit(resp_error_init(msg)); }
+
+static void reply_commands(void) {
+ resp_object *a = resp_array_init();
+ resp_array_append_bulk(a, "COMMAND");
+ resp_array_append_bulk(a, "INFO");
+ resp_array_append_bulk(a, "PORT");
+ resp_array_append_bulk(a, "RIF");
+ resp_array_append_bulk(a, "NEIGH");
+ resp_array_append_bulk(a, "ROUTE");
+ resp_array_append_bulk(a, "RESYNC");
+ emit(a);
+}
+
+// resp.c has no bulk constructor, only simple/error/array, so build it here.
+static resp_object *bulk_init(const char *s) {
+ resp_object *o = calloc(1, sizeof(*o));
+ if (!o) return NULL;
+ o->type = RESPT_BULK;
+ o->u.s = strdup(s);
+ return o;
+}
+
+static void reply_info(void) {
+ char buf[256];
+ snprintf(buf, sizeof(buf),
+ "# Server\r\n"
+ "name:bcm\r\n"
+ "version:0.1.0\r\n"
+ "\r\n"
+ "# Stats\r\n"
+ "hardware_detected:%d\r\n",
+ hardware_present() ? 1 : 0);
+ emit(bulk_init(buf));
+}
+
+static const char *arg(const resp_object *cmd, size_t i) {
+ if (!cmd || cmd->type != RESPT_ARRAY || i >= cmd->u.arr.n) return NULL;
+ const resp_object *e = &cmd->u.arr.elem[i];
+ if (e->type != RESPT_BULK && e->type != RESPT_SIMPLE) return NULL;
+ return e->u.s;
+}
+
+int main(void) {
+ for (;;) {
+ resp_object *cmd = resp_read(STDIN_FILENO);
+ if (!cmd) break; // EOF or malformed: linkd will restart us
+
+ const char *verb = arg(cmd, 0);
+ if (!verb) {
+ reply_err("ERR malformed command");
+ resp_free(cmd);
+ continue;
+ }
+
+ if (!strcasecmp(verb, "COMMAND")) {
+ reply_commands();
+ } else if (!strcasecmp(verb, "INFO")) {
+ reply_info();
+ } else if (!strcasecmp(verb, "AUTH")) {
+ reply_ok();
+ } else if (!strcasecmp(verb, "PORT") || !strcasecmp(verb, "RIF") ||
+ !strcasecmp(verb, "NEIGH") || !strcasecmp(verb, "ROUTE") ||
+ !strcasecmp(verb, "RESYNC")) {
+ // TODO: program the ASIC through the OpenBCM SDK.
+ reply_err("ERR not implemented");
+ } else {
+ reply_err("ERR unknown command");
+ }
+
+ resp_free(cmd);
+ }
+ return 0;
+}
diff --git a/src/cli/client.c b/src/cli/client.c
@@ -0,0 +1,152 @@
+#define _GNU_SOURCE
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/time.h>
+#include <sys/un.h>
+#include <unistd.h>
+
+#include "finwo/resp.h"
+
+#include "cli/client.h"
+#include "config/daemon.h"
+
+#define CLIENT_TIMEOUT_S 10
+
+static int connect_daemon(const char **path_out) {
+ const char *sockpath = linkd_client_socket();
+ *path_out = sockpath;
+
+ int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
+ if (fd < 0) {
+ perror("socket");
+ return -1;
+ }
+
+ struct sockaddr_un addr;
+ memset(&addr, 0, sizeof(addr));
+ addr.sun_family = AF_UNIX;
+ strncpy(addr.sun_path, sockpath, sizeof(addr.sun_path) - 1);
+
+ if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
+ fprintf(stderr, "connect %s failed: %s\nIs linkd running?\n",
+ sockpath, strerror(errno));
+ close(fd);
+ return -1;
+ }
+
+ struct timeval tv = { .tv_sec = CLIENT_TIMEOUT_S, .tv_usec = 0 };
+ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
+ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
+ return fd;
+}
+
+static const char *map_get(const resp_object *m, const char *key) {
+ if (!m || m->type != RESPT_ARRAY) return NULL;
+ for (size_t i = 0; i + 1 < m->u.arr.n; i += 2) {
+ const resp_object *k = &m->u.arr.elem[i];
+ if ((k->type == RESPT_BULK || k->type == RESPT_SIMPLE) && k->u.s &&
+ !strcmp(k->u.s, key)) {
+ const resp_object *v = &m->u.arr.elem[i + 1];
+ if (v->type == RESPT_BULK || v->type == RESPT_SIMPLE) return v->u.s;
+ }
+ }
+ return NULL;
+}
+
+// An interface map renders as the config syntax it came from, so ifquery
+// output can be pasted back into /etc/network/interfaces.
+static int render_iface_map(const resp_object *m) {
+ const char *name = map_get(m, "name");
+ if (!name) return 0;
+
+ printf("iface %s\n", name);
+ for (size_t i = 0; i + 1 < m->u.arr.n; i += 2) {
+ const resp_object *k = &m->u.arr.elem[i];
+ const resp_object *v = &m->u.arr.elem[i + 1];
+ if (k->type != RESPT_BULK && k->type != RESPT_SIMPLE) continue;
+ if (!strcmp(k->u.s, "name")) continue;
+ if (v->type == RESPT_BULK || v->type == RESPT_SIMPLE) {
+ printf(" %s %s\n", k->u.s, v->u.s);
+ } else if (v->type == RESPT_INT) {
+ printf(" %s %lld\n", k->u.s, v->u.i);
+ }
+ }
+ return 1;
+}
+
+static void render(const resp_object *o, int depth) {
+ if (!o) return;
+ switch (o->type) {
+ case RESPT_SIMPLE:
+ printf("%s\n", o->u.s ? o->u.s : "");
+ break;
+ case RESPT_BULK:
+ if (o->u.s) fputs(o->u.s, stdout);
+ if (o->u.s && o->u.s[strlen(o->u.s) - 1] != '\n') fputc('\n', stdout);
+ break;
+ case RESPT_INT:
+ printf("%lld\n", o->u.i);
+ break;
+ case RESPT_ARRAY:
+ if (depth == 0 && render_iface_map(o)) break;
+ for (size_t i = 0; i < o->u.arr.n; i++) {
+ const resp_object *e = &o->u.arr.elem[i];
+ if (e->type == RESPT_ARRAY && render_iface_map(e)) continue;
+ render(e, depth + 1);
+ }
+ break;
+ default:
+ break;
+ }
+}
+
+int linkd_call(int argc, const char **argv) {
+ const char *sockpath = NULL;
+ int fd = connect_daemon(&sockpath);
+ if (fd < 0) return 1;
+
+ resp_object *req = resp_array_init();
+ if (!req) { close(fd); return 1; }
+ for (int i = 0; i < argc; i++) resp_array_append_bulk(req, argv[i]);
+
+ char *buf = NULL;
+ size_t len = 0;
+ int rc = resp_serialize(req, &buf, &len);
+ resp_free(req);
+ if (rc != 0) { close(fd); return 1; }
+
+ size_t sent = 0;
+ while (sent < len) {
+ ssize_t n = write(fd, buf + sent, len - sent);
+ if (n <= 0) {
+ if (n < 0 && errno == EINTR) continue;
+ fprintf(stderr, "write failed: %s\n", strerror(errno));
+ free(buf);
+ close(fd);
+ return 1;
+ }
+ sent += (size_t)n;
+ }
+ free(buf);
+
+ resp_object *reply = resp_read(fd);
+ close(fd);
+
+ if (!reply) {
+ fprintf(stderr, "no reply from linkd\n");
+ return 1;
+ }
+
+ int status = 0;
+ if (reply->type == RESPT_ERROR) {
+ fprintf(stderr, "%s\n", reply->u.s ? reply->u.s : "error");
+ status = 1;
+ } else {
+ render(reply, 0);
+ }
+ resp_free(reply);
+ return status;
+}
diff --git a/src/cli/client.h b/src/cli/client.h
@@ -0,0 +1,8 @@
+#ifndef __LINKD_CLI_CLIENT_H__
+#define __LINKD_CLI_CLIENT_H__
+
+// Connect to the daemon, send argv as a RESP command, render the reply.
+// Returns a process exit code: 0 on success, 1 on error.
+int linkd_call(int argc, const char **argv);
+
+#endif // __LINKD_CLI_CLIENT_H__
diff --git a/src/cli/ifdown.c b/src/cli/ifdown.c
@@ -1,49 +1,16 @@
#define _GNU_SOURCE
-#include <errno.h>
#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <sys/socket.h>
-#include <sys/un.h>
-#include <unistd.h>
-#include "config/daemon.h"
-#include "registry.h"
+#include "cli/client.h"
+#include "registry.h"
int main_ifdown(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "usage: ifdown <interface>\n");
return 1;
}
- char out[4096];
- int len = snprintf(out, sizeof(out), "ifdown");
- for (int i = 1; i < argc; i++) len += snprintf(out+len, sizeof(out)-len, " %s", argv[i]);
- len += snprintf(out+len, sizeof(out)-len, "\n");
- const char *sockpath = linkd_client_socket();
- int sock = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
- if (sock < 0) { perror("socket"); return 1; }
- struct sockaddr_un addr = {0};
- addr.sun_family = AF_UNIX;
- strncpy(addr.sun_path, sockpath, sizeof(addr.sun_path)-1);
- if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
- fprintf(stderr, "ifdown: connect %s failed: %s\nIs linkd running?\n", sockpath, strerror(errno));
- close(sock);
- return 1;
- }
- if (write(sock, out, len) != len) { perror("write"); close(sock); return 1; }
- // Half-close: signals EOF to linkd so it stops reading and replies. Without this
- // the server blocks waiting for a second command until its receive timeout.
- shutdown(sock, SHUT_WR);
- char buf[4096];
- int saw_err = 0;
- FILE *f = fdopen(sock, "r");
- if (!f) { perror("fdopen"); close(sock); return 1; }
- while (fgets(buf, sizeof(buf), f)) {
- if (!strncmp(buf, "ERR", 3)) saw_err = 1;
- fputs(buf, stdout);
- }
- fclose(f);
- return saw_err ? 1 : 0;
+ const char *cmd[] = { "IFDOWN", argv[1] };
+ return linkd_call(2, cmd);
}
__attribute__((constructor))
diff --git a/src/cli/ifquery.c b/src/cli/ifquery.c
@@ -1,45 +1,16 @@
#define _GNU_SOURCE
-#include <errno.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <sys/socket.h>
-#include <sys/un.h>
-#include <unistd.h>
-#include "config/daemon.h"
-#include "registry.h"
+#include <stddef.h>
+#include "cli/client.h"
+#include "registry.h"
int main_ifquery(int argc, char *argv[]) {
- char out[4096];
- int len = snprintf(out, sizeof(out), "ifquery");
- for (int i = 1; i < argc; i++) len += snprintf(out+len, sizeof(out)-len, " %s", argv[i]);
- len += snprintf(out+len, sizeof(out)-len, "\n");
- const char *sockpath = linkd_client_socket();
- int sock = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
- if (sock < 0) { perror("socket"); return 1; }
- struct sockaddr_un addr = {0};
- addr.sun_family = AF_UNIX;
- strncpy(addr.sun_path, sockpath, sizeof(addr.sun_path)-1);
- if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
- fprintf(stderr, "ifquery: connect %s failed: %s\nIs linkd running?\n", sockpath, strerror(errno));
- close(sock);
- return 1;
- }
- if (write(sock, out, len) != len) { perror("write"); close(sock); return 1; }
- // Half-close: signals EOF to linkd so it stops reading and replies. Without this
- // the server blocks waiting for a second command until its receive timeout.
- shutdown(sock, SHUT_WR);
- char buf[4096];
- int saw_err = 0;
- FILE *f = fdopen(sock, "r");
- if (!f) { perror("fdopen"); close(sock); return 1; }
- while (fgets(buf, sizeof(buf), f)) {
- if (!strncmp(buf, "ERR", 3)) saw_err = 1;
- fputs(buf, stdout);
+ const char *cmd[2] = { "IFQUERY", NULL };
+ if (argc > 1) {
+ cmd[1] = argv[1];
+ return linkd_call(2, cmd);
}
- fclose(f);
- return saw_err ? 1 : 0;
+ return linkd_call(1, cmd);
}
__attribute__((constructor))
diff --git a/src/cli/ifreload.c b/src/cli/ifreload.c
@@ -1,48 +1,11 @@
#define _GNU_SOURCE
-#include <errno.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <sys/socket.h>
-#include <sys/un.h>
-#include <unistd.h>
-#include "config/daemon.h"
+#include "cli/client.h"
#include "registry.h"
-
int main_ifreload(int argc, char *argv[]) {
- if (argc != 1) {
- fprintf(stderr, "usage: ifreload\n");
- return 1;
- }
- (void)argv;
- char out[4096];
- int len = snprintf(out, sizeof(out), "ifreload\n");
- const char *sockpath = linkd_client_socket();
- int sock = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
- if (sock < 0) { perror("socket"); return 1; }
- struct sockaddr_un addr = {0};
- addr.sun_family = AF_UNIX;
- strncpy(addr.sun_path, sockpath, sizeof(addr.sun_path)-1);
- if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
- fprintf(stderr, "ifreload: connect %s failed: %s\nIs linkd running?\n", sockpath, strerror(errno));
- close(sock);
- return 1;
- }
- if (write(sock, out, len) != len) { perror("write"); close(sock); return 1; }
- // Half-close: signals EOF to linkd so it stops reading and replies. Without this
- // the server blocks waiting for a second command until its receive timeout.
- shutdown(sock, SHUT_WR);
- char buf[4096];
- int saw_err = 0;
- FILE *f = fdopen(sock, "r");
- if (!f) { perror("fdopen"); close(sock); return 1; }
- while (fgets(buf, sizeof(buf), f)) {
- if (!strncmp(buf, "ERR", 3)) saw_err = 1;
- fputs(buf, stdout);
- }
- fclose(f);
- return saw_err ? 1 : 0;
+ (void)argc; (void)argv;
+ const char *cmd[] = { "IFRELOAD" };
+ return linkd_call(1, cmd);
}
__attribute__((constructor))
diff --git a/src/cli/ifup.c b/src/cli/ifup.c
@@ -1,54 +1,16 @@
#define _GNU_SOURCE
-#include <errno.h>
#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <sys/socket.h>
-#include <sys/un.h>
-#include <unistd.h>
-#include "config/daemon.h"
-#include "registry.h"
+#include "cli/client.h"
+#include "registry.h"
int main_ifup(int argc, char *argv[]) {
- // ifup <if> -- thin wrapper, same socket logic as linkctl but fixed command
if (argc < 2) {
fprintf(stderr, "usage: ifup <interface>\n");
return 1;
}
- // Build command: "ifup <if> [extra args]"
- char out[4096];
- int len = snprintf(out, sizeof(out), "ifup");
- for (int i = 1; i < argc; i++) {
- len += snprintf(out+len, sizeof(out)-len, " %s", argv[i]);
- }
- len += snprintf(out+len, sizeof(out)-len, "\n");
-
- const char *sockpath = linkd_client_socket();
- int sock = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
- if (sock < 0) { perror("socket"); return 1; }
- struct sockaddr_un addr = {0};
- addr.sun_family = AF_UNIX;
- strncpy(addr.sun_path, sockpath, sizeof(addr.sun_path)-1);
- if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
- fprintf(stderr, "ifup: connect %s failed: %s\nIs linkd running?\n", sockpath, strerror(errno));
- close(sock);
- return 1;
- }
- if (write(sock, out, len) != len) { perror("write"); close(sock); return 1; }
- // Half-close: signals EOF to linkd so it stops reading and replies. Without this
- // the server blocks waiting for a second command until its receive timeout.
- shutdown(sock, SHUT_WR);
- char buf[4096];
- int saw_err = 0;
- FILE *f = fdopen(sock, "r");
- if (!f) { perror("fdopen"); close(sock); return 1; }
- while (fgets(buf, sizeof(buf), f)) {
- if (!strncmp(buf, "ERR", 3)) saw_err = 1;
- fputs(buf, stdout);
- }
- fclose(f);
- return saw_err ? 1 : 0;
+ const char *cmd[] = { "IFUP", argv[1] };
+ return linkd_call(2, cmd);
}
__attribute__((constructor))
diff --git a/src/cli/linkctl.c b/src/cli/linkctl.c
@@ -1,95 +1,97 @@
#define _GNU_SOURCE
-#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
-#include <libgen.h>
-#include <sys/socket.h>
-#include <sys/un.h>
+#include <strings.h>
+#include <termios.h>
#include <unistd.h>
-#include "config/daemon.h"
+#include "cli/client.h"
#include "registry.h"
+#include "util/auth.h"
+static void usage(const char *prog) {
+ fprintf(stderr, "usage: %s <command> [args...]\n", prog);
+ fprintf(stderr, " %s COMMAND list what the daemon accepts\n", prog);
+ fprintf(stderr, " %s hash [user] generate a password-file entry\n", prog);
+}
-int main_linkctl(int argc, char *argv[]) {
- // linkctl is a tiny shim: linkctl <cmd> [args...] -> send "cmd [args...]\n" to /var/run/linkd.sock
- // It does NOT handle being called as ifup via argv[0]; those have their own mains.
- if (argc < 2) {
- fprintf(stderr, "usage: %s <ifup|ifdown|ifquery|ifreload> [args...]\n", argv[0]);
- return 1;
+static char *read_password(const char *prompt) {
+ struct termios old, quiet;
+ int tty = isatty(STDIN_FILENO);
+ char *line = NULL;
+ size_t cap = 0;
+
+ if (tty) {
+ fprintf(stderr, "%s", prompt);
+ fflush(stderr);
+ if (tcgetattr(STDIN_FILENO, &old) == 0) {
+ quiet = old;
+ quiet.c_lflag &= (tcflag_t)~ECHO;
+ tcsetattr(STDIN_FILENO, TCSAFLUSH, &quiet);
+ } else {
+ tty = 0;
+ }
}
- const char *cmd = argv[1];
- int cmd_argc = argc - 2;
- char **cmd_argv = argv + 2;
- int sock;
- struct sockaddr_un addr;
- char buf[4096];
- const char *sockpath = linkd_client_socket();
- sock = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
- if (sock < 0) {
- perror("socket");
- return 1;
+ ssize_t n = getline(&line, &cap, stdin);
+
+ if (tty) {
+ tcsetattr(STDIN_FILENO, TCSAFLUSH, &old);
+ fprintf(stderr, "\n");
}
- memset(&addr, 0, sizeof(addr));
- addr.sun_family = AF_UNIX;
- strncpy(addr.sun_path, sockpath, sizeof(addr.sun_path)-1);
+ if (n <= 0) { free(line); return NULL; }
+ char *nl = strpbrk(line, "\r\n");
+ if (nl) *nl = '\0';
+ return line;
+}
+
+static int cmd_hash(int argc, char *argv[]) {
+ const char *user = argc > 1 ? argv[1] : NULL;
- if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
- fprintf(stderr, "linkctl: connect %s failed: %s\n", sockpath, strerror(errno));
- fprintf(stderr, "Is linkd running?\n");
- close(sock);
+ char *pass = read_password("Password: ");
+ if (!pass || !*pass) {
+ fprintf(stderr, "linkctl: empty password\n");
+ free(pass);
return 1;
}
-
- // Send command: "cmd [args...]\n"
- {
- char out[4096];
- int len = 0;
- len += snprintf(out+len, sizeof(out)-len, "%s", cmd);
- for (int i = 0; i < cmd_argc; i++) {
- len += snprintf(out+len, sizeof(out)-len, " %s", cmd_argv[i]);
- }
- len += snprintf(out+len, sizeof(out)-len, "\n");
- if (write(sock, out, len) != len) {
- perror("write");
- close(sock);
+ if (isatty(STDIN_FILENO)) {
+ char *again = read_password("Again: ");
+ if (!again || strcmp(pass, again) != 0) {
+ fprintf(stderr, "linkctl: passwords do not match\n");
+ free(pass);
+ free(again);
return 1;
}
- // Half-close: signals EOF to linkd so it stops reading and replies. Without
- // this the server blocks waiting for a second command until its timeout.
- shutdown(sock, SHUT_WR);
+ free(again);
}
- // Read response and pipe to stdout
- // Protocol: server sends lines, first line is OK/ERR, but for ifquery the dump is the response
- // We just pipe everything to stdout, and exit 0 on OK, 1 on ERR
- int saw_ok = 0;
- int saw_err = 0;
- FILE *f = fdopen(sock, "r");
- if (!f) {
- perror("fdopen");
- close(sock);
+ char *hash = auth_make_hash(pass, AUTH_DEFAULT_ITERATIONS);
+ memset(pass, 0, strlen(pass));
+ free(pass);
+
+ if (!hash) {
+ fprintf(stderr, "linkctl: could not generate hash\n");
return 1;
}
- while (fgets(buf, sizeof(buf), f)) {
- // Check for OK/ERR prefix on first line? Just print and detect
- if (!saw_ok && !saw_err) {
- if (!strncmp(buf, "OK", 2)) saw_ok = 1;
- else if (!strncmp(buf, "ERR", 3)) saw_err = 1;
- }
- fputs(buf, stdout);
- // For non-ifquery, the response is just OK/ERR line
- // For ifquery, there may be multiple lines plus OK
- }
- fclose(f);
- // If we saw ERR, exit 1
- if (saw_err) return 1;
+
+ if (user) printf("%s:%s:readonly\n", user, hash);
+ else printf("%s\n", hash);
+ free(hash);
return 0;
}
+int main_linkctl(int argc, char *argv[]) {
+ if (argc < 2) {
+ usage(argv[0]);
+ return 1;
+ }
+ if (!strcasecmp(argv[1], "hash")) return cmd_hash(argc - 1, argv + 1);
+
+ return linkd_call(argc - 1, (const char **)argv + 1);
+}
+
__attribute__((constructor))
static void register_linkctl(void) {
cli_register("linkctl", main_linkctl);
diff --git a/src/cli/linkd.c b/src/cli/linkd.c
@@ -20,7 +20,7 @@ extern "C" {
#include "config/ports.h"
#include "config/ifaces.h"
#include "dataplane.h"
-#include "dataplane/registry.h"
+#include "dataplane/plugin.h"
#include "ipc.h"
#include "netlink/netlink.h"
#include "netlink/rtnl.h"
@@ -66,11 +66,8 @@ static void stop_handler(int sig) {
}
static int apply_ports(void) {
- const struct dp_ops *dp = dp_active();
- struct linkd_port *cur;
- struct dp_port port;
-
- if (!dp || !dp->port_apply) return 0;
+ struct linkd_port *cur;
+ struct dp_port port;
for ( cur = ports_list() ; cur ; cur = cur->next ) {
memset(&port, 0, sizeof(port));
@@ -79,7 +76,7 @@ static int apply_ports(void) {
port.fec = cur->fec;
port.autoneg = cur->autoneg;
- if (dp->port_apply(&port) != DP_RET_OK) {
+ if (dp_port_apply(&port) != DP_RET_OK) {
log_warn("%s: failed to apply port configuration", cur->name);
}
}
@@ -219,7 +216,6 @@ int main_linkd(int argc, char **argv) {
// Cast for argparse which wants const char**
const char **c_argv = (const char **)argv;
char *config_path = LINKD_CONFIG_PATH;
- char *dataplane = NULL;
char *loglevel = "info";
char *logfile_path = NULL;
char *ready_file = "";
@@ -230,7 +226,6 @@ int main_linkd(int argc, char **argv) {
struct argparse_option options[] = {
OPT_HELP(),
OPT_STRING('c', "config", &config_path, "Configuration file (default: " LINKD_CONFIG_PATH ")", NULL, 0, 0),
- OPT_STRING('d', "dataplane", &dataplane, "Force a dataplane backend", NULL, 0, 0),
OPT_STRING('v', "verbosity", &loglevel, "log verbosity: fatal,error,warn,info,debug,trace (default: info)", NULL, 0, 0),
OPT_STRING(0, "log", &logfile_path, "also write log to file (SIGHUP reopens for logrotate)", NULL, 0, 0),
OPT_INTEGER(0, "resync-interval", &resync_interval, "seconds between full resyncs (0 disables timer)", NULL, 0, 0),
@@ -242,7 +237,6 @@ int main_linkd(int argc, char **argv) {
daemon_cfg_register();
ports_register();
ifaces_register();
- ipc_register();
struct argparse argparse;
argparse_init(&argparse, options, usage, 0);
@@ -321,23 +315,13 @@ int main_linkd(int argc, char **argv) {
}
}
- // Startup order is load-bearing: ports -> backend select -> init ->
- // port_apply -> nl_open -> initial resync -> ready -> event loop. frr must
- // not start before the ready file exists or zebra misses the swpN netdevs.
+ // Startup order is load-bearing: ports -> plugins -> port_apply -> nl_open
+ // -> initial resync -> ready -> event loop. frr must not start before the
+ // ready file exists or zebra misses the swpN netdevs.
//
- // The built-in kernel backend is registered first so it acts as the
- // fallback; plugins installed by hardware packages are probed ahead of it.
- dp_register(dp_kernel_ops());
- dp_plugins_load(LINKD_DATAPLANE_DIR);
-
- if (dp_select(dataplane) != DP_RET_OK) {
- return 1;
- }
-
- if (dp_init() != DP_RET_OK) {
- log_error("dataplane: initialisation failed");
- return 1;
- }
+ // A plugin that fails to start is not fatal: the backoff retries it, and
+ // with none configured every dataplane call is a no-op.
+ dp_start();
if (ipc_init() != 0) {
log_warn("ipc: init failed, ifup/ifquery will not be available");
@@ -349,7 +333,7 @@ int main_linkd(int argc, char **argv) {
nl_fd = nl_open();
if (nl_fd < 0) {
- dp_fini();
+ dp_stop();
ports_free();
ifaces_free();
ipc_fini();
@@ -364,7 +348,7 @@ int main_linkd(int argc, char **argv) {
log_fatal("netlink: initial resync failed");
close(nl_fd);
ipc_fini();
- dp_fini();
+ dp_stop();
ports_free();
ifaces_free();
return 1;
@@ -374,7 +358,7 @@ int main_linkd(int argc, char **argv) {
if (ready_publish(ready_file) != 0) {
close(nl_fd);
ipc_fini();
- dp_fini();
+ dp_stop();
ports_free();
ifaces_free();
return 1;
@@ -387,7 +371,7 @@ int main_linkd(int argc, char **argv) {
ipc_fini();
ready_remove(ready_file);
- dp_fini();
+ dp_stop();
ports_free();
ifaces_free();
diff --git a/src/config/daemon.c b/src/config/daemon.c
@@ -9,6 +9,7 @@
#include "finwo/cnfparse.h"
#include "config/daemon.h"
+#include "dataplane/plugin.h"
#include "util/config.h"
static struct linkd_daemon_cfg cfg;
@@ -147,7 +148,21 @@ static struct cnf_directive *h_plugin(FILE *fd, struct cnf_directive *dir, void
continue;
}
- // Not ours: hand it back so the caller re-dispatches it.
+ // Unknown to us: the plugin gets first refusal. Starting it here is what
+ // makes its config vocabulary discoverable at all -- CONFIG LIST cannot
+ // be asked of a process that is not running.
+ struct dp_plugin *dp = dp_plugin_for(p->target);
+ if (dp && dp_plugin_config_supports(dp, sub->name)) {
+ if (dp_plugin_config_set(dp, sub->name, sub->argv, sub->argc) != 0) {
+ cfg_error("plugin %s rejected `%s'", p->target, sub->name);
+ cnf_directive_free(sub);
+ return NULL;
+ }
+ cnf_directive_free(sub);
+ continue;
+ }
+
+ // Neither side claims it: ends the stanza, hand it back for re-dispatch.
return sub;
}
}
diff --git a/src/dataplane.h b/src/dataplane.h
@@ -8,19 +8,14 @@
#define DP_RET_ERROR -1
#define DP_RET_OK 0
-// Bumped whenever struct dp_ops changes shape once a real plugin has shipped.
-// Until then the structs below are fluid: extend freely without bumping.
-// Plugins declaring a different ABI are refused at load time rather than
-// crashing us later.
-#define LINKD_DATAPLANE_ABI 1
-
-// Where dlopen'd backends live. The openbcm package drops its backend here;
-// on hardware without a switching ASIC the directory is simply empty and we
-// fall back to the built-in kernel dataplane.
-#define LINKD_DATAPLANE_DIR "/usr/lib/linkd/dataplane"
-
-// Symbol every plugin must export
-#define LINKD_DATAPLANE_SYM "linkd_dataplane_ops"
+// Plugins are separate processes speaking RESP, declared in /etc/linkd.cnf:
+//
+// plugin /usr/lib/linkd/bcm spawned, RESP over stdio
+// plugin tcp://user:pass@host:6789 connected to, RESP over the socket
+//
+// They advertise what they implement via COMMAND, and each operation is
+// broadcast to every plugin advertising its verb. There is no in-process ABI
+// and nothing is dlopen'd, so a plugin can be written in any language.
enum dp_fec {
DP_FEC_UNSET = 0,
@@ -75,55 +70,35 @@ struct dp_route {
uint32_t table; // kernel route-table id; RT_TABLE_MAIN = default VRF
};
-struct dp_ops {
- uint32_t abi;
- const char *name;
-
- // Return DP_RET_OK if this backend can drive the hardware in this box.
- // Must be cheap and must not have side effects; it is called on every
- // backend before one is chosen.
- int (*probe)(void);
-
- int (*init)(void);
- void (*fini)(void);
+// Start every plugin declared in the config. Plugins that fail to start are
+// retried with backoff; this is not an error in itself.
+int dp_start(void);
+void dp_stop(void);
- // Applies ports.cnf properties.
- int (*port_apply)(const struct dp_port *port);
-
- // Driven by the netlink mirror, not by configuration: ifupdown sets these
- // on the netdev and we replicate them into the hardware.
- int (*port_admin)(const char *ifname, bool up);
- int (*port_mtu)(const char *ifname, uint32_t mtu);
-
- int (*rif_add)(const struct dp_rif *rif);
- int (*rif_del)(const struct dp_rif *rif);
-
- int (*neigh_add)(const struct dp_neigh *neigh);
- int (*neigh_del)(const struct dp_neigh *neigh);
-
- int (*route_add)(const struct dp_route *route);
- int (*route_del)(const struct dp_route *route);
-
- // Drop all state and rebuild it from the kernel. Netlink can and does drop
- // messages under load (ENOBUFS), so event-following alone drifts; every
- // backend needs to support a full resync.
- int (*resync)(void);
-};
+// True when at least one plugin is configured. Without any, every operation
+// below is a successful no-op -- the kernel has already done the work by the
+// time these are called.
+int dp_have_plugins(void);
-// Built-in backend. Always available, always probes successfully.
-const struct dp_ops * dp_kernel_ops(void);
+// Operations. Each is broadcast to every plugin advertising the verb; all of
+// them are called even if an earlier one fails, so plugins cannot end up in
+// states that disagree with each other. Returns DP_RET_ERROR if any
+// non-optional plugin failed.
+int dp_port_apply(const struct dp_port *port);
+int dp_port_admin(const char *ifname, bool up);
+int dp_port_mtu(const char *ifname, uint32_t mtu);
-// Load plugins from LINKD_DATAPLANE_DIR. Safe to call when the directory is
-// absent or empty.
-int dp_plugins_load(const char *dir);
+int dp_rif_add(const struct dp_rif *rif);
+int dp_rif_del(const struct dp_rif *rif);
-// Pick a backend. `force` may name one explicitly (skips probing), or be NULL
-// to probe every registered backend and take the first that claims the box.
-int dp_select(const char *force);
+int dp_neigh_add(const struct dp_neigh *neigh);
+int dp_neigh_del(const struct dp_neigh *neigh);
-const struct dp_ops * dp_active(void);
+int dp_route_add(const struct dp_route *route);
+int dp_route_del(const struct dp_route *route);
-int dp_init(void);
-void dp_fini(void);
+// Netlink drops messages under load (ENOBUFS), so following events alone
+// drifts; plugins are told to rebuild from scratch periodically.
+int dp_resync(void);
#endif // __LINKD_DATAPLANE_H__
diff --git a/src/dataplane/kernel.c b/src/dataplane/kernel.c
@@ -1,73 +0,0 @@
-// Built-in dataplane backend.
-//
-// This is the "there is no switching ASIC" case: generic x86_64, a VM, or a
-// soft-router. The Linux kernel is already the forwarding engine, so every
-// entry point here is a genuine no-op rather than an unimplemented stub:
-//
-// - addresses, MTU, MAC and admin state are applied by ifupdown directly to
-// the netdev, so there is nothing to replicate
-// - routes and neighbours are already in the FIB by the time we observe the
-// netlink message announcing them
-// - speed / FEC / autoneg are PHY properties of a real NIC, driven through
-// ethtool, and meaningless in a VM
-//
-// Mirroring any of it back into the kernel would be circular. linkd on generic
-// hardware is therefore close to inert by design: it parses configuration,
-// validates it, and lets Linux do the work.
-//
-// The table field on rif/neigh/route is accepted and ignored here: there is
-// nothing per-VRF to replicate when the kernel itself forwards.
-
-#include <stdio.h>
-
-#include "dataplane.h"
-
-static int kernel_probe(void) {
- // Always usable -- this is the fallback of last resort.
- return DP_RET_OK;
-}
-
-static int kernel_init(void) {
- fprintf(stderr, "dataplane/kernel: forwarding handled by the kernel\n");
- return DP_RET_OK;
-}
-
-static void kernel_fini(void) {
-}
-
-static int kernel_port_apply(const struct dp_port *port) { (void)port; return DP_RET_OK; }
-static int kernel_port_admin(const char *ifname, bool up) { (void)ifname; (void)up; return DP_RET_OK; }
-static int kernel_port_mtu(const char *ifname, uint32_t mtu) { (void)ifname; (void)mtu; return DP_RET_OK; }
-
-static int kernel_rif_add(const struct dp_rif *rif) { (void)rif; return DP_RET_OK; }
-static int kernel_rif_del(const struct dp_rif *rif) { (void)rif; return DP_RET_OK; }
-static int kernel_neigh_add(const struct dp_neigh *neigh) { (void)neigh; return DP_RET_OK; }
-static int kernel_neigh_del(const struct dp_neigh *neigh) { (void)neigh; return DP_RET_OK; }
-static int kernel_route_add(const struct dp_route *route) { (void)route; return DP_RET_OK; }
-static int kernel_route_del(const struct dp_route *route) { (void)route; return DP_RET_OK; }
-
-static int kernel_resync(void) {
- return DP_RET_OK;
-}
-
-static const struct dp_ops kernel_ops = {
- .abi = LINKD_DATAPLANE_ABI,
- .name = "kernel",
- .probe = kernel_probe,
- .init = kernel_init,
- .fini = kernel_fini,
- .port_apply = kernel_port_apply,
- .port_admin = kernel_port_admin,
- .port_mtu = kernel_port_mtu,
- .rif_add = kernel_rif_add,
- .rif_del = kernel_rif_del,
- .neigh_add = kernel_neigh_add,
- .neigh_del = kernel_neigh_del,
- .route_add = kernel_route_add,
- .route_del = kernel_route_del,
- .resync = kernel_resync,
-};
-
-const struct dp_ops * dp_kernel_ops(void) {
- return &kernel_ops;
-}
diff --git a/src/dataplane/ops.c b/src/dataplane/ops.c
@@ -0,0 +1,185 @@
+#define _GNU_SOURCE
+#include <arpa/inet.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+
+#include "finwo/resp.h"
+#include "rxi/log.h"
+
+#include "dataplane.h"
+#include "dataplane/plugin.h"
+
+#define MAX_ARGV 24
+
+// Every supporter is called even after one fails, so plugins cannot end up
+// disagreeing about what was applied. `optional` failures do not fail the op.
+static int broadcast(const char **argv, int argc) {
+ int failed = 0;
+ int sent = 0;
+
+ for (struct dp_plugin *p = dp_plugin_list(); p; p = p->next) {
+ // Retry a down plugin here: an operation is exactly when we want it back.
+ if (!p->up) dp_plugin_up(p);
+ if (!dp_plugin_supports(p, argv[0])) continue;
+
+ sent++;
+ resp_object *r = dp_plugin_call(p, argv, argc);
+ if (!r) {
+ if (!p->optional) failed = 1;
+ continue;
+ }
+ if (r->type == RESPT_ERROR) {
+ log_error("plugin %s: %s %s: %s", p->target, argv[0],
+ argc > 1 ? argv[1] : "", r->u.s ? r->u.s : "error");
+ if (!p->optional) failed = 1;
+ }
+ resp_free(r);
+ }
+
+ (void)sent;
+ return failed ? DP_RET_ERROR : DP_RET_OK;
+}
+
+static const char *fec_str(enum dp_fec f) {
+ switch (f) {
+ case DP_FEC_OFF: return "off";
+ case DP_FEC_AUTO: return "auto";
+ case DP_FEC_RS: return "rs";
+ case DP_FEC_BASER: return "baser";
+ default: return NULL;
+ }
+}
+
+// dp_addr -> "10.0.0.1/24"
+static int addr_str(const struct dp_addr *a, char *out, size_t len) {
+ char ip[INET6_ADDRSTRLEN];
+ if (!inet_ntop(a->family, a->addr, ip, sizeof(ip))) return -1;
+ snprintf(out, len, "%s/%u", ip, a->prefixlen);
+ return 0;
+}
+
+static int addr_plain(const struct dp_addr *a, char *out, size_t len) {
+ return inet_ntop(a->family, a->addr, out, (socklen_t)len) ? 0 : -1;
+}
+
+int dp_port_apply(const struct dp_port *port) {
+ if (!dp_have_plugins()) return DP_RET_OK;
+
+ const char *argv[MAX_ARGV];
+ char speed[16], autoneg[12];
+ int argc = 0;
+
+ argv[argc++] = "PORT";
+ argv[argc++] = "APPLY";
+ argv[argc++] = port->name;
+
+ if (port->speed) {
+ snprintf(speed, sizeof(speed), "%u", port->speed);
+ argv[argc++] = "speed";
+ argv[argc++] = speed;
+ }
+ const char *f = fec_str(port->fec);
+ if (f) {
+ argv[argc++] = "fec";
+ argv[argc++] = f;
+ }
+ if (port->autoneg >= 0) {
+ snprintf(autoneg, sizeof(autoneg), "%d", port->autoneg);
+ argv[argc++] = "autoneg";
+ argv[argc++] = autoneg;
+ }
+ return broadcast(argv, argc);
+}
+
+int dp_port_admin(const char *ifname, bool up) {
+ if (!dp_have_plugins()) return DP_RET_OK;
+ const char *argv[] = { "PORT", "ADMIN", ifname, up ? "up" : "down" };
+ return broadcast(argv, 4);
+}
+
+int dp_port_mtu(const char *ifname, uint32_t mtu) {
+ if (!dp_have_plugins()) return DP_RET_OK;
+ char m[16];
+ snprintf(m, sizeof(m), "%u", mtu);
+ const char *argv[] = { "PORT", "MTU", ifname, m };
+ return broadcast(argv, 4);
+}
+
+static int rif_op(const char *sub, const struct dp_rif *rif) {
+ if (!dp_have_plugins()) return DP_RET_OK;
+ char addr[INET6_ADDRSTRLEN + 8], table[16];
+ if (addr_str(&rif->addr, addr, sizeof(addr)) != 0) return DP_RET_ERROR;
+ snprintf(table, sizeof(table), "%u", rif->table);
+ const char *argv[] = { "RIF", sub, rif->ifname, addr, "table", table };
+ return broadcast(argv, 6);
+}
+
+int dp_rif_add(const struct dp_rif *rif) { return rif_op("ADD", rif); }
+int dp_rif_del(const struct dp_rif *rif) { return rif_op("DEL", rif); }
+
+static int neigh_op(const char *sub, const struct dp_neigh *n) {
+ if (!dp_have_plugins()) return DP_RET_OK;
+ char ip[INET6_ADDRSTRLEN], mac[18], table[16];
+ if (addr_plain(&n->addr, ip, sizeof(ip)) != 0) return DP_RET_ERROR;
+ snprintf(mac, sizeof(mac), "%02x:%02x:%02x:%02x:%02x:%02x",
+ n->mac[0], n->mac[1], n->mac[2], n->mac[3], n->mac[4], n->mac[5]);
+ snprintf(table, sizeof(table), "%u", n->table);
+ const char *argv[] = { "NEIGH", sub, n->ifname, ip, mac, "table", table };
+ return broadcast(argv, 7);
+}
+
+int dp_neigh_add(const struct dp_neigh *n) { return neigh_op("ADD", n); }
+int dp_neigh_del(const struct dp_neigh *n) { return neigh_op("DEL", n); }
+
+// ROUTE ADD <dst> [via <gw> dev <if>]... [metric N] [table N]
+// Nexthops are flattened rather than nested so a shell plugin can parse them.
+static int route_op(const char *sub, const struct dp_route *r) {
+ if (!dp_have_plugins()) return DP_RET_OK;
+
+ const char *argv[MAX_ARGV];
+ char dst[INET6_ADDRSTRLEN + 8];
+ char gw[4][INET6_ADDRSTRLEN];
+ char metric[16], table[16];
+ int argc = 0;
+
+ if (addr_str(&r->dst, dst, sizeof(dst)) != 0) return DP_RET_ERROR;
+
+ argv[argc++] = "ROUTE";
+ argv[argc++] = sub;
+ argv[argc++] = dst;
+
+ size_t nh = r->nh_count;
+ if (nh > 4) nh = 4; // argv budget; ECMP wider than this is truncated
+ for (size_t i = 0; i < nh; i++) {
+ if (argc + 4 >= MAX_ARGV) break;
+ if (addr_plain(&r->nh[i].gw, gw[i], sizeof(gw[i])) == 0) {
+ argv[argc++] = "via";
+ argv[argc++] = gw[i];
+ }
+ if (r->nh[i].ifname) {
+ argv[argc++] = "dev";
+ argv[argc++] = r->nh[i].ifname;
+ }
+ }
+ if (r->metric) {
+ snprintf(metric, sizeof(metric), "%u", r->metric);
+ argv[argc++] = "metric";
+ argv[argc++] = metric;
+ }
+ snprintf(table, sizeof(table), "%u", r->table);
+ argv[argc++] = "table";
+ argv[argc++] = table;
+
+ return broadcast(argv, argc);
+}
+
+int dp_route_add(const struct dp_route *r) { return route_op("ADD", r); }
+int dp_route_del(const struct dp_route *r) { return route_op("DEL", r); }
+
+int dp_resync(void) {
+ if (!dp_have_plugins()) return DP_RET_OK;
+ const char *argv[] = { "RESYNC" };
+ return broadcast(argv, 1);
+}
diff --git a/src/dataplane/plugin.c b/src/dataplane/plugin.c
@@ -1,67 +1,452 @@
-// Dataplane plugin loader.
-//
-// A single linkd image is identical on every machine. Hardware support arrives as a
-// package: on a Broadcom box the `openbcm` package installs its kernel modules
-// and drops a backend into LINKD_DATAPLANE_DIR. On anything else the directory
-// is absent or empty and we quietly fall back to the built-in kernel backend.
-//
-// A plugin is a shared object exporting `struct dp_ops linkd_dataplane_ops`.
-
-#include <dirent.h>
-#include <dlfcn.h>
+#define _GNU_SOURCE
+#include <errno.h>
+#include <netdb.h>
+#include <signal.h>
#include <stdio.h>
+#include <stdlib.h>
#include <string.h>
+#include <strings.h>
+#include <sys/socket.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+#include "finwo/resp.h"
+#include "rxi/log.h"
+
+#include "config/daemon.h"
#include "dataplane.h"
+#include "dataplane/plugin.h"
+
+#define PLUGIN_TIMEOUT_S 5
+#define BACKOFF_MIN_MS 500
+#define BACKOFF_MAX_MS 30000
+#define MAX_CONFIG_ARGV 32
-#include "registry.h"
+static struct dp_plugin *plugins = NULL;
-int dp_plugins_load(const char *dir) {
- struct dirent *ent;
- DIR *dh;
- char path[1024];
- void *handle;
- struct dp_ops *ops;
- size_t len;
- int loaded = 0;
+static void strlist_free(char ***list, size_t *n) {
+ for (size_t i = 0; i < *n; i++) free((*list)[i]);
+ free(*list);
+ *list = NULL;
+ *n = 0;
+}
- if (!dir) dir = LINKD_DATAPLANE_DIR;
+static int strlist_add(char ***list, size_t *n, const char *s) {
+ char **g = realloc(*list, sizeof(char *) * (*n + 1));
+ if (!g) return -1;
+ *list = g;
+ (*list)[*n] = strdup(s);
+ if (!(*list)[*n]) return -1;
+ (*n)++;
+ return 0;
+}
- dh = opendir(dir);
- if (!dh) {
- // Not an error: no plugin directory simply means no hardware backends.
- return 0;
+static int strlist_has(char **list, size_t n, const char *s) {
+ for (size_t i = 0; i < n; i++) {
+ if (!strcasecmp(list[i], s)) return 1;
}
+ return 0;
+}
- while ((ent = readdir(dh))) {
- len = strlen(ent->d_name);
- if (len < 4) continue;
- if (strcmp(ent->d_name + len - 3, ".so")) continue;
+static void now_plus_ms(struct timespec *out, int ms) {
+ clock_gettime(CLOCK_MONOTONIC, out);
+ out->tv_sec += ms / 1000;
+ out->tv_nsec += (long)(ms % 1000) * 1000000L;
+ if (out->tv_nsec >= 1000000000L) { out->tv_sec++; out->tv_nsec -= 1000000000L; }
+}
- snprintf(path, sizeof(path), "%s/%s", dir, ent->d_name);
+static int deadline_passed(const struct timespec *t) {
+ struct timespec now;
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ if (now.tv_sec != t->tv_sec) return now.tv_sec > t->tv_sec;
+ return now.tv_nsec >= t->tv_nsec;
+}
+
+static int set_timeouts(int fd) {
+ struct timeval tv = { .tv_sec = PLUGIN_TIMEOUT_S, .tv_usec = 0 };
+ if (setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0) return -1;
+ if (setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) != 0) return -1;
+ return 0;
+}
+
+// socketpair, not pipes: SO_RCVTIMEO works on it, so a wedged plugin cannot
+// block the daemon forever.
+static int spawn_child(struct dp_plugin *p) {
+ int sv[2];
+ if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv) != 0) {
+ log_error("plugin %s: socketpair: %s", p->target, strerror(errno));
+ return -1;
+ }
+
+ pid_t pid = fork();
+ if (pid < 0) {
+ log_error("plugin %s: fork: %s", p->target, strerror(errno));
+ close(sv[0]);
+ close(sv[1]);
+ return -1;
+ }
- handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
- if (!handle) {
- fprintf(stderr, "dataplane: %s: %s\n", path, dlerror());
- continue;
+ if (pid == 0) {
+ // stderr is inherited, so plugin diagnostics reach the daemon log.
+ dup2(sv[1], STDIN_FILENO);
+ dup2(sv[1], STDOUT_FILENO);
+ if (sv[1] > STDERR_FILENO) close(sv[1]);
+ close(sv[0]);
+ signal(SIGPIPE, SIG_DFL);
+ execl(p->target, p->target, (char *)NULL);
+ fprintf(stderr, "plugin %s: exec failed: %s\n", p->target, strerror(errno));
+ _exit(127);
+ }
+
+ close(sv[1]);
+ if (set_timeouts(sv[0]) != 0) {
+ log_error("plugin %s: setsockopt: %s", p->target, strerror(errno));
+ close(sv[0]);
+ return -1;
+ }
+ p->fd = sv[0];
+ p->pid = pid;
+ return 0;
+}
+
+// tcp://[user:pass@]host[:port]
+static int parse_tcp(const char *url, char **host, char **port, char **user, char **pass) {
+ const char *rest = url + 6; // skip tcp://
+ const char *at = strrchr(rest, '@');
+ *user = *pass = NULL;
+
+ if (at) {
+ size_t clen = (size_t)(at - rest);
+ char *cred = strndup(rest, clen);
+ if (!cred) return -1;
+ char *colon = strchr(cred, ':');
+ if (colon) {
+ *colon = '\0';
+ *user = strdup(cred);
+ *pass = strdup(colon + 1);
+ } else {
+ *user = strdup(cred);
}
+ free(cred);
+ rest = at + 1;
+ }
+
+ const char *colon = strrchr(rest, ':');
+ if (colon) {
+ *host = strndup(rest, (size_t)(colon - rest));
+ *port = strdup(colon + 1);
+ } else {
+ *host = strdup(rest);
+ *port = strdup("6789");
+ }
+ return (*host && *port) ? 0 : -1;
+}
+
+static int connect_tcp(struct dp_plugin *p) {
+ char *host = NULL, *port = NULL;
+ free(p->user); free(p->pass);
+ p->user = p->pass = NULL;
+
+ if (parse_tcp(p->target, &host, &port, &p->user, &p->pass) != 0) {
+ log_error("plugin %s: malformed address", p->target);
+ free(host); free(port);
+ return -1;
+ }
+
+ struct addrinfo hints = { .ai_family = AF_UNSPEC, .ai_socktype = SOCK_STREAM };
+ struct addrinfo *res = NULL;
+ int gai = getaddrinfo(host, port, &hints, &res);
+ free(host); free(port);
+ if (gai != 0) {
+ log_error("plugin %s: resolve: %s", p->target, gai_strerror(gai));
+ return -1;
+ }
- ops = dlsym(handle, LINKD_DATAPLANE_SYM);
- if (!ops) {
- fprintf(stderr, "dataplane: %s: missing symbol `%s`\n", path, LINKD_DATAPLANE_SYM);
- dlclose(handle);
- continue;
+ int fd = -1;
+ for (struct addrinfo *ai = res; ai; ai = ai->ai_next) {
+ fd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC, ai->ai_protocol);
+ if (fd < 0) continue;
+ if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) break;
+ close(fd);
+ fd = -1;
+ }
+ freeaddrinfo(res);
+
+ if (fd < 0) {
+ log_error("plugin %s: connect failed: %s", p->target, strerror(errno));
+ return -1;
+ }
+ if (set_timeouts(fd) != 0) {
+ close(fd);
+ return -1;
+ }
+ p->fd = fd;
+ p->pid = 0;
+ return 0;
+}
+
+static int write_all(int fd, const char *buf, size_t len) {
+ while (len) {
+ ssize_t n = write(fd, buf, len);
+ if (n <= 0) {
+ if (n < 0 && errno == EINTR) continue;
+ return -1;
}
+ buf += n;
+ len -= (size_t)n;
+ }
+ return 0;
+}
+
+resp_object *dp_plugin_call(struct dp_plugin *p, const char **argv, int argc) {
+ if (!p->up || p->fd < 0) return NULL;
+
+ resp_object *req = resp_array_init();
+ if (!req) return NULL;
+ for (int i = 0; i < argc; i++) resp_array_append_bulk(req, argv[i]);
- if (dp_register(ops) != DP_RET_OK) {
- dlclose(handle);
- continue;
+ char *buf = NULL;
+ size_t len = 0;
+ if (resp_serialize(req, &buf, &len) != 0) {
+ resp_free(req);
+ return NULL;
+ }
+ resp_free(req);
+
+ int rc = write_all(p->fd, buf, len);
+ free(buf);
+ if (rc != 0) {
+ dp_plugin_down(p, "write failed");
+ return NULL;
+ }
+
+ // resp_read returns NULL for timeout, EOF and parse errors alike; all three
+ // mean the plugin is unusable.
+ resp_object *reply = resp_read(p->fd);
+ if (!reply) {
+ dp_plugin_down(p, "no reply (timeout, EOF or malformed)");
+ return NULL;
+ }
+ return reply;
+}
+
+static int reply_is_error(const resp_object *o) {
+ return o && o->type == RESPT_ERROR;
+}
+
+static void load_commands(struct dp_plugin *p) {
+ strlist_free(&p->commands, &p->commands_n);
+ const char *argv[] = { "COMMAND" };
+ resp_object *r = dp_plugin_call(p, argv, 1);
+ if (!r) return;
+
+ if (r->type == RESPT_ARRAY) {
+ for (size_t i = 0; i < r->u.arr.n; i++) {
+ resp_object *e = &r->u.arr.elem[i];
+ // Accept a flat list of names or redis' array-per-command form.
+ const char *name = NULL;
+ if (e->type == RESPT_BULK || e->type == RESPT_SIMPLE) {
+ name = e->u.s;
+ } else if (e->type == RESPT_ARRAY && e->u.arr.n > 0) {
+ resp_object *f = &e->u.arr.elem[0];
+ if (f->type == RESPT_BULK || f->type == RESPT_SIMPLE) name = f->u.s;
+ }
+ if (name) strlist_add(&p->commands, &p->commands_n, name);
}
+ }
+ resp_free(r);
+}
- // Deliberately leaked: the ops table stays live for the process lifetime.
- loaded++;
+static void load_config_keys(struct dp_plugin *p) {
+ strlist_free(&p->config_keys, &p->config_keys_n);
+ if (!dp_plugin_supports(p, "CONFIG")) return;
+
+ const char *argv[] = { "CONFIG", "LIST" };
+ resp_object *r = dp_plugin_call(p, argv, 2);
+ if (!r) return;
+
+ if (r->type == RESPT_ARRAY) {
+ for (size_t i = 0; i < r->u.arr.n; i++) {
+ resp_object *e = &r->u.arr.elem[i];
+ if (e->type == RESPT_BULK || e->type == RESPT_SIMPLE) {
+ strlist_add(&p->config_keys, &p->config_keys_n, e->u.s);
+ }
+ }
+ }
+ resp_free(r);
+}
+
+// redis INFO format; hardware_detected is the only field we interpret.
+static void load_info(struct dp_plugin *p) {
+ p->hardware = -1;
+ const char *argv[] = { "INFO" };
+ resp_object *r = dp_plugin_call(p, argv, 1);
+ if (!r) return;
+
+ if ((r->type == RESPT_BULK || r->type == RESPT_SIMPLE) && r->u.s) {
+ const char *k = strstr(r->u.s, "hardware_detected:");
+ if (k) p->hardware = atoi(k + strlen("hardware_detected:")) ? 1 : 0;
+ }
+ resp_free(r);
+}
+
+static int authenticate(struct dp_plugin *p) {
+ if (!p->pass && !p->user) return 0;
+ const char *argv[3];
+ int argc = 0;
+ argv[argc++] = "AUTH";
+ if (p->user && p->pass) {
+ argv[argc++] = p->user;
+ argv[argc++] = p->pass;
+ } else {
+ argv[argc++] = p->user ? p->user : p->pass;
+ }
+ resp_object *r = dp_plugin_call(p, argv, argc);
+ if (!r) return -1;
+ int bad = reply_is_error(r);
+ if (bad) log_error("plugin %s: AUTH rejected: %s", p->target, r->u.s ? r->u.s : "");
+ resp_free(r);
+ return bad ? -1 : 0;
+}
+
+void dp_plugin_down(struct dp_plugin *p, const char *why) {
+ if (p->fd >= 0) { close(p->fd); p->fd = -1; }
+ if (p->pid > 0) {
+ kill(p->pid, SIGTERM);
+ waitpid(p->pid, NULL, 0);
+ p->pid = 0;
}
+ if (p->up) log_error("plugin %s: down (%s)", p->target, why ? why : "unknown");
+ p->up = 0;
+
+ p->backoff_ms = p->backoff_ms ? p->backoff_ms * 2 : BACKOFF_MIN_MS;
+ if (p->backoff_ms > BACKOFF_MAX_MS) p->backoff_ms = BACKOFF_MAX_MS;
+ now_plus_ms(&p->retry_at, p->backoff_ms);
+}
+
+int dp_plugin_up(struct dp_plugin *p) {
+ if (p->up) return 0;
+ if (p->backoff_ms && !deadline_passed(&p->retry_at)) return -1;
+
+ int rc = !strncmp(p->target, "tcp://", 6) ? connect_tcp(p) : spawn_child(p);
+ if (rc != 0) {
+ dp_plugin_down(p, "start failed");
+ return -1;
+ }
+
+ // Provisionally up so dp_plugin_call() will talk to it during handshake.
+ p->up = 1;
+
+ if (authenticate(p) != 0) {
+ dp_plugin_down(p, "authentication failed");
+ return -1;
+ }
+
+ load_commands(p);
+ if (!p->up) return -1;
+ load_info(p);
+ if (!p->up) return -1;
+ load_config_keys(p);
+ if (!p->up) return -1;
+
+ if (p->hardware == 0) {
+ log_error("plugin %s: reports no supported hardware, not using it", p->target);
+ dp_plugin_down(p, "no hardware");
+ return -1;
+ }
+
+ p->backoff_ms = 0;
+ log_info("plugin %s: up, %zu commands", p->target, p->commands_n);
+ return 0;
+}
+
+int dp_plugin_supports(const struct dp_plugin *p, const char *verb) {
+ return p->up && strlist_has(p->commands, p->commands_n, verb);
+}
+
+int dp_plugin_config_supports(const struct dp_plugin *p, const char *key) {
+ return strlist_has(p->config_keys, p->config_keys_n, key);
+}
+
+struct dp_plugin *dp_plugin_list(void) {
+ return plugins;
+}
+
+static struct dp_plugin *plugin_entry(const char *target) {
+ for (struct dp_plugin *p = plugins; p; p = p->next) {
+ if (!strcmp(p->target, target)) return p;
+ }
+ struct dp_plugin *p = calloc(1, sizeof(*p));
+ if (!p) return NULL;
+ p->target = strdup(target);
+ if (!p->target) { free(p); return NULL; }
+ p->fd = -1;
+ struct dp_plugin **tail = &plugins;
+ while (*tail) tail = &(*tail)->next;
+ *tail = p;
+ return p;
+}
+
+struct dp_plugin *dp_plugin_for(const char *target) {
+ struct dp_plugin *p = plugin_entry(target);
+ if (!p) return NULL;
+ dp_plugin_up(p);
+ return p;
+}
+
+int dp_plugin_config_set(struct dp_plugin *p, const char *key, char **argv, size_t argc) {
+ const char *a[MAX_CONFIG_ARGV];
+ size_t n = 0;
+ a[n++] = "CONFIG";
+ a[n++] = "SET";
+ a[n++] = key;
+ for (size_t i = 0; i < argc && n < MAX_CONFIG_ARGV; i++) a[n++] = argv[i];
+
+ resp_object *r = dp_plugin_call(p, a, (int)n);
+ if (!r) return -1;
+ int bad = reply_is_error(r);
+ if (bad) {
+ log_error("plugin %s: CONFIG SET %s: %s", p->target, key, r->u.s ? r->u.s : "error");
+ }
+ resp_free(r);
+ return bad ? -1 : 0;
+}
+
+int dp_start(void) {
+ const struct linkd_daemon_cfg *cfg = daemon_cfg();
+
+ for (struct linkd_plugin_cfg *c = cfg->plugins; c; c = c->next) {
+ struct dp_plugin *p = plugin_entry(c->target);
+ if (!p) return DP_RET_ERROR;
+ p->optional = c->optional;
+ // A plugin that cannot start yet is not fatal; the backoff retries it.
+ dp_plugin_up(p);
+ }
+ return DP_RET_OK;
+}
+
+void dp_stop(void) {
+ struct dp_plugin *p = plugins;
+ while (p) {
+ struct dp_plugin *next = p->next;
+ if (p->fd >= 0) close(p->fd);
+ if (p->pid > 0) {
+ kill(p->pid, SIGTERM);
+ waitpid(p->pid, NULL, 0);
+ }
+ strlist_free(&p->commands, &p->commands_n);
+ strlist_free(&p->config_keys, &p->config_keys_n);
+ free(p->target);
+ free(p->user);
+ free(p->pass);
+ free(p);
+ p = next;
+ }
+ plugins = NULL;
+}
- closedir(dh);
- return loaded;
+int dp_have_plugins(void) {
+ return plugins != NULL;
}
diff --git a/src/dataplane/plugin.h b/src/dataplane/plugin.h
@@ -0,0 +1,56 @@
+#ifndef __LINKD_DATAPLANE_PLUGIN_H__
+#define __LINKD_DATAPLANE_PLUGIN_H__
+
+#include <sys/types.h>
+#include <time.h>
+
+#include "finwo/resp.h"
+
+struct dp_plugin {
+ char *target;
+ int optional;
+
+ int fd; // socketpair to the child, or the tcp socket
+ pid_t pid; // 0 when connected over tcp
+ char *user; // tcp:// credentials, if any
+ char *pass;
+
+ int up;
+ int hardware; // hardware_detected from INFO
+
+ char **commands; // verbs from COMMAND, upper-cased
+ size_t commands_n;
+ char **config_keys; // directives from CONFIG LIST
+ size_t config_keys_n;
+
+ int backoff_ms; // 0 until the first failure
+ struct timespec retry_at;
+
+ struct dp_plugin *next;
+};
+
+// Send argv as a RESP array and read one reply. NULL on transport failure,
+// which also marks the plugin down. Caller owns the result.
+resp_object *dp_plugin_call(struct dp_plugin *p, const char **argv, int argc);
+
+// True if COMMAND advertised this verb.
+int dp_plugin_supports(const struct dp_plugin *p, const char *verb);
+
+// True if CONFIG LIST advertised this directive.
+int dp_plugin_config_supports(const struct dp_plugin *p, const char *key);
+
+// Start, or reconnect after backoff. Returns 0 when usable.
+int dp_plugin_up(struct dp_plugin *p);
+void dp_plugin_down(struct dp_plugin *p, const char *why);
+
+// Plugin list, in declaration order.
+struct dp_plugin *dp_plugin_list(void);
+
+// Look up or create the entry for a target, starting it if needed. Called
+// during config parsing (to ask about directives) and by dp_start().
+struct dp_plugin *dp_plugin_for(const char *target);
+
+// CONFIG SET <key> <argv...>. Returns 0 on +OK.
+int dp_plugin_config_set(struct dp_plugin *p, const char *key, char **argv, size_t argc);
+
+#endif // __LINKD_DATAPLANE_PLUGIN_H__
diff --git a/src/dataplane/registry.c b/src/dataplane/registry.c
@@ -1,79 +0,0 @@
-#include <stdio.h>
-#include <string.h>
-#include <strings.h>
-
-#include "dataplane.h"
-
-#include "registry.h"
-
-#define DP_MAX_BACKENDS 16
-
-static const struct dp_ops *backend[DP_MAX_BACKENDS];
-static int backend_count = 0;
-static const struct dp_ops *active = NULL;
-
-int dp_register(const struct dp_ops *ops) {
- if (!ops) return DP_RET_ERROR;
-
- if (ops->abi != LINKD_DATAPLANE_ABI) {
- fprintf(stderr, "dataplane: refusing backend `%s`: abi %u, expected %u\n",
- ops->name ? ops->name : "(unnamed)", ops->abi, LINKD_DATAPLANE_ABI);
- return DP_RET_ERROR;
- }
-
- if (!ops->name || !ops->probe || !ops->init) {
- fprintf(stderr, "dataplane: refusing incomplete backend\n");
- return DP_RET_ERROR;
- }
-
- if (backend_count >= DP_MAX_BACKENDS) {
- fprintf(stderr, "dataplane: backend table full, dropping `%s`\n", ops->name);
- return DP_RET_ERROR;
- }
-
- backend[backend_count++] = ops;
- return DP_RET_OK;
-}
-
-int dp_select(const char *force) {
- int i;
-
- if (force) {
- for (i = 0; i < backend_count; i++) {
- if (!strcasecmp(backend[i]->name, force)) {
- active = backend[i];
- fprintf(stderr, "dataplane: using `%s` (forced)\n", active->name);
- return DP_RET_OK;
- }
- }
- fprintf(stderr, "dataplane: no backend named `%s`\n", force);
- return DP_RET_ERROR;
- }
-
- // Plugins are probed before the built-in kernel backend, which is
- // registered first and always succeeds -- so iterate in reverse.
- for (i = backend_count - 1; i >= 0; i--) {
- if (backend[i]->probe() != DP_RET_OK) continue;
- active = backend[i];
- fprintf(stderr, "dataplane: using `%s`\n", active->name);
- return DP_RET_OK;
- }
-
- fprintf(stderr, "dataplane: no usable backend\n");
- return DP_RET_ERROR;
-}
-
-const struct dp_ops * dp_active(void) {
- return active;
-}
-
-int dp_init(void) {
- if (!active) return DP_RET_ERROR;
- return active->init();
-}
-
-void dp_fini(void) {
- if (!active) return;
- if (active->fini) active->fini();
- active = NULL;
-}
diff --git a/src/dataplane/registry.h b/src/dataplane/registry.h
@@ -1,10 +0,0 @@
-#ifndef __LINKD_DATAPLANE_REGISTRY_H__
-#define __LINKD_DATAPLANE_REGISTRY_H__
-
-#include "dataplane.h"
-
-// Add a backend to the selection table. Rejects backends whose ABI does not
-// match or which omit mandatory entry points.
-int dp_register(const struct dp_ops *ops);
-
-#endif // __LINKD_DATAPLANE_REGISTRY_H__
diff --git a/src/ipc.c b/src/ipc.c
@@ -1,283 +1,96 @@
#define _GNU_SOURCE
-#include <libgen.h>
+#include <arpa/inet.h>
#include <errno.h>
+#include <libgen.h>
#include <net/if.h>
+#include <netdb.h>
+#include <netinet/in.h>
+#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/stat.h>
+#include <sys/types.h>
#include <sys/un.h>
#include <unistd.h>
-#include <sys/types.h>
+#include "finwo/resp.h"
#include "rxi/log.h"
-#include "util/config.h"
#include "config/daemon.h"
#include "config/ifaces.h"
#include "config/ports.h"
#include "ipc.h"
#include "netlink/netlink.h"
#include "netlink/rtnl.h"
-
-static int ipc_sock = -1;
-static const char *ipc_path = NULL;
-
-int ipc_fd(void) {
- return ipc_sock;
-}
-
-int ipc_init(void) {
- struct sockaddr_un addr;
-
- ipc_path = daemon_cfg_unix_socket();
- if (!ipc_path) {
- log_error("ipc: no unix:// listen address configured");
- return -1;
- }
-
- // Create the socket's directory; early boot may not have mounted it yet.
- {
- char *d = strdup(ipc_path);
- if (d) { mkdir(dirname(d), 0755); free(d); }
- }
- unlink(ipc_path);
-
- ipc_sock = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
- if (ipc_sock < 0) {
- log_error("ipc: socket failed: %s", strerror(errno));
- return -1;
- }
-
- memset(&addr, 0, sizeof(addr));
- addr.sun_family = AF_UNIX;
- strncpy(addr.sun_path, ipc_path, sizeof(addr.sun_path)-1);
-
- if (bind(ipc_sock, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
- log_error("ipc: bind %s failed: %s", ipc_path, strerror(errno));
- close(ipc_sock);
- ipc_sock = -1;
- return -1;
- }
-
- if (chmod(ipc_path, 0600) != 0) {
- log_warn("ipc: chmod %s failed: %s", ipc_path, strerror(errno));
- }
-
- if (listen(ipc_sock, 8) != 0) {
- log_error("ipc: listen failed: %s", strerror(errno));
- close(ipc_sock);
- ipc_sock = -1;
- unlink(ipc_path);
- return -1;
- }
-
- log_info("ipc: listening on %s", ipc_path);
+#include "util/auth.h"
+
+#define RBUF_MAX (1024 * 1024)
+
+struct listener {
+ int fd;
+ int is_unix;
+ char *path; // unix only, for unlink on shutdown
+};
+
+struct ipc_conn {
+ int fd;
+ int is_unix;
+ int role;
+ uid_t uid;
+
+ char *rbuf; size_t rlen, rcap;
+ char *wbuf; size_t wlen, wcap, wsent;
+
+ int close_after_write;
+ struct ipc_conn *next;
+};
+
+static struct listener listeners[IPC_MAX_LISTENERS];
+static int listener_n = 0;
+static struct ipc_conn *conns = NULL;
+static int conn_n = 0;
+
+static int wbuf_append(struct ipc_conn *c, const char *data, size_t len) {
+ if (c->wlen + len > c->wcap) {
+ size_t cap = c->wcap ? c->wcap : 1024;
+ while (cap < c->wlen + len) cap *= 2;
+ char *g = realloc(c->wbuf, cap);
+ if (!g) return -1;
+ c->wbuf = g;
+ c->wcap = cap;
+ }
+ memcpy(c->wbuf + c->wlen, data, len);
+ c->wlen += len;
return 0;
}
-void ipc_fini(void) {
- if (ipc_sock >= 0) {
- close(ipc_sock);
- ipc_sock = -1;
+static void reply_obj(struct ipc_conn *c, resp_object *o) {
+ if (!o) return;
+ char *buf = NULL;
+ size_t len = 0;
+ if (resp_serialize(o, &buf, &len) == 0) {
+ wbuf_append(c, buf, len);
+ free(buf);
}
- unlink(ipc_path);
+ resp_free(o);
}
-// Helpers to send response
-static void ipc_send_ok(FILE *out, const char *msg) {
- if (msg) fprintf(out, "OK %s\n", msg);
- else fprintf(out, "OK\n");
+static void reply_ok(struct ipc_conn *c) {
+ reply_obj(c, resp_simple_init("OK"));
}
-static void ipc_send_err(FILE *out, const char *msg) {
- fprintf(out, "ERR %s\n", msg ? msg : "unknown error");
+static void reply_err(struct ipc_conn *c, const char *fmt, ...) {
+ char msg[512];
+ va_list ap;
+ va_start(ap, fmt);
+ vsnprintf(msg, sizeof(msg), fmt, ap);
+ va_end(ap);
+ reply_obj(c, resp_error_init(msg));
}
-// Forward declarations for handlers (implemented below, using netlink helpers)
-static int handle_ifup(FILE *out, const char *ifname, uid_t uid);
-static int handle_ifdown(FILE *out, const char *ifname, uid_t uid);
-static int handle_ifreload(FILE *out, uid_t uid);
-static int handle_ifquery(FILE *out, const char *ifname);
-
-// IPC namespace handlers -- these are registered with cfg_ns_get("ipc")
-// They are invoked via cfg_parse on the connected socket's FILE*
-static struct cnf_directive *cfg_handle_ifup(FILE *fd, struct cnf_directive *dir, void *user) {
- FILE *out = (FILE*)user;
- (void)fd;
- log_info("ipc: cfg_handle_ifup argc=%d", dir->argc);
- uid_t uid = 0;
- extern uid_t ipc_current_uid;
- uid = ipc_current_uid;
- if (uid != 0) {
- ipc_send_err(out, "permission denied: ifup requires root");
- cnf_directive_free(dir);
- return NULL;
- }
- if (dir->argc != 1) {
- ipc_send_err(out, "ifup expects 1 arg: <ifname>");
- cnf_directive_free(dir);
- return NULL;
- }
- log_info("ipc: cfg_handle_ifup %s uid=%d", dir->argv[0], uid);
- if (handle_ifup(out, dir->argv[0], uid) == 0) ipc_send_ok(out, NULL);
- else log_info("ipc: handle_ifup %s failed", dir->argv[0]);
- cnf_directive_free(dir);
- return NULL;
-}
-
-static struct cnf_directive *cfg_handle_ifdown(FILE *fd, struct cnf_directive *dir, void *user) {
- FILE *out = (FILE*)user;
- (void)fd;
- extern uid_t ipc_current_uid;
- uid_t uid = ipc_current_uid;
- if (uid != 0) {
- ipc_send_err(out, "permission denied: ifdown requires root");
- cnf_directive_free(dir);
- return NULL;
- }
- if (dir->argc != 1) {
- ipc_send_err(out, "ifdown expects 1 arg: <ifname>");
- cnf_directive_free(dir);
- return NULL;
- }
- if (handle_ifdown(out, dir->argv[0], uid) == 0) ipc_send_ok(out, NULL);
- cnf_directive_free(dir);
- return NULL;
-}
-
-static struct cnf_directive *cfg_handle_ifreload(FILE *fd, struct cnf_directive *dir, void *user) {
- FILE *out = (FILE*)user;
- (void)fd;
- extern uid_t ipc_current_uid;
- uid_t uid = ipc_current_uid;
- if (uid != 0) {
- ipc_send_err(out, "permission denied: ifreload requires root");
- cnf_directive_free(dir);
- return NULL;
- }
- if (dir->argc != 0) {
- ipc_send_err(out, "ifreload expects no args");
- cnf_directive_free(dir);
- return NULL;
- }
- if (handle_ifreload(out, uid) == 0) ipc_send_ok(out, NULL);
- cnf_directive_free(dir);
- return NULL;
-}
-
-static struct cnf_directive *cfg_handle_ifquery(FILE *fd, struct cnf_directive *dir, void *user) {
- FILE *out = (FILE*)user;
- (void)fd;
- log_info("ipc: ifquery argc=%d", dir->argc);
- const char *ifname = NULL;
- if (dir->argc > 1) {
- ipc_send_err(out, "ifquery expects 0 or 1 arg");
- cnf_directive_free(dir);
- return NULL;
- }
- if (dir->argc == 1) ifname = dir->argv[0];
- log_info("ipc: ifquery %s", ifname ? ifname : "(all)");
- handle_ifquery(out, ifname);
- cnf_directive_free(dir);
- return NULL;
-}
-
-// Global for current uid (single-threaded, one client at a time)
-uid_t ipc_current_uid = 0;
-
-int ipc_handle(void) {
- int cfd;
- struct sockaddr_un addr;
- socklen_t alen = sizeof(addr);
- struct ucred cred;
- socklen_t clen = sizeof(cred);
- FILE *fin = NULL;
- FILE *fout = NULL;
- int fd_dup;
-
- // The LISTENING socket is non-blocking (it lives in the nl_run poll set), but
- // the ACCEPTED connection must be blocking: the client connects and writes its
- // command as two separate syscalls, so a non-blocking read here returns EAGAIN
- // whenever the bytes have not landed yet. stdio maps that to EOF, cfg_parse
- // sees an empty stream, no handler runs and the client gets an empty reply.
- // That is a race with no upper bound in wall-clock terms - it is not fixable
- // by sleeping in the caller, only by blocking here.
- cfd = accept4(ipc_sock, (struct sockaddr*)&addr, &alen, SOCK_CLOEXEC);
- if (cfd < 0) {
- if (errno == EAGAIN || errno == EWOULDBLOCK) return 0;
- log_error("ipc: accept failed: %s", strerror(errno));
- return -1;
- }
-
- // Blocking, but never indefinitely: linkd is single-threaded, so a client that
- // connects and then stalls would otherwise wedge the whole daemon (netlink
- // included). A short timeout bounds that without reintroducing the race.
- {
- struct timeval tv = { .tv_sec = 5, .tv_usec = 0 };
- setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
- setsockopt(cfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
- }
-
- // Get peer credentials
- if (getsockopt(cfd, SOL_SOCKET, SO_PEERCRED, &cred, &clen) == 0) {
- ipc_current_uid = cred.uid;
- } else {
- ipc_current_uid = 99999;
- }
-
- // Use fdopen for reading and writing -- need separate FILE* for read and write to handle duplex?
- // We'll use one FILE* for reading commands, and the fd directly for writing responses.
- // Simpler: use fdopen for reading, and use fdopen dup for writing.
- fd_dup = dup(cfd);
- if (fd_dup < 0) {
- close(cfd);
- return -1;
- }
- fin = fdopen(cfd, "r");
- fout = fdopen(fd_dup, "w");
- if (!fin || !fout) {
- if (fin) fclose(fin); else close(cfd);
- if (fout) fclose(fout); else close(fd_dup);
- return -1;
- }
-
- // Set line buffering
- setvbuf(fout, NULL, _IONBF, 0);
-
- // Parse one or more commands from this connection (each line is a command)
- // Use cfg_parse on the ipc namespace
- struct cfg_ns *ns = cfg_ns_get("ipc");
- // cfg_parse will read directives from fin and dispatch to handlers, which write to fout
- // It handles multiple lines until EOF.
- cfg_parse(ns, NULL, fin, fout);
-
- // Ensure OK is sent if handler didn't already? Our handlers send OK/ERR themselves.
- // Flush and close
- fflush(fout);
- fclose(fin); // also closes cfd
- fclose(fout);
- ipc_current_uid = 0;
- return 0;
-}
-
-// Register IPC commands
-void ipc_register(void) {
- struct cfg_ns *ns = cfg_ns_get("ipc");
- cfg_register_directive(ns, "ifup", cfg_handle_ifup);
- cfg_register_directive(ns, "ifdown", cfg_handle_ifdown);
- cfg_register_directive(ns, "ifreload", cfg_handle_ifreload);
- cfg_register_directive(ns, "ifquery", cfg_handle_ifquery);
-}
-
-// Stubs for netlink helpers (to be implemented in netlink.c or here)
-// For now, provide minimal implementations that just log and return OK
-// The real implementation will be wired in the next commit (Wire netlink apply)
-
-#include <sys/wait.h>
-
static int run_hook(const char *cmd) {
if (!cmd || !*cmd) return 0;
log_info("ipc: running hook: %s", cmd);
@@ -289,30 +102,29 @@ static int run_hook(const char *cmd) {
return 0;
}
-static int handle_ifup(FILE *out, const char *ifname, uid_t uid) {
- (void)uid;
+static int do_ifup(struct ipc_conn *c, const char *ifname) {
struct linkd_iface *iface = ifaces_find(ifname);
if (!iface) {
log_info("ipc: ifup %s (not in interfaces, just bringing link up)", ifname);
if (rtnl_link_up(ifname) != 0) {
- fprintf(out, "ERR ifup %s failed\n", ifname);
+ reply_err(c, "ifup %s failed", ifname);
return -1;
}
return 0;
}
log_info("ipc: ifup %s", ifname);
if (iface->pre_up && run_hook(iface->pre_up) != 0) {
- fprintf(out, "ERR pre-up hook failed for %s\n", ifname);
+ reply_err(c, "pre-up hook failed for %s", ifname);
return -1;
}
- // VLAN creation first (needs rawdev)
+
if (iface->vlan_id >= 0) {
const char *raw = iface->vlan_raw_device;
char tmp[IF_NAMESIZE];
if (!raw) {
char *dot = strrchr(iface->name, '.');
if (dot) {
- size_t len = dot - iface->name;
+ size_t len = (size_t)(dot - iface->name);
if (len < sizeof(tmp)) {
memcpy(tmp, iface->name, len);
tmp[len] = '\0';
@@ -325,16 +137,16 @@ static int handle_ifup(FILE *out, const char *ifname, uid_t uid) {
rtnl_vlan_create(ifname, raw, iface->vlan_id);
}
}
- // VRF device: create before anything else touches it. `ifup <vrf>` on its
- // own must work, not only a full apply.
+
+ // `ifup <vrf>` alone must work, not only a full apply.
if (iface_is_vrf(iface)) {
log_info("ipc: ifup %s vrf table %d", ifname, iface->vrf_table);
rtnl_vrf_create(ifname, (uint32_t)iface->vrf_table);
}
- // Bridge creation -- classification shared with apply_interfaces, see
- // config/ifaces.c:iface_is_bridge().
+
if (iface_is_bridge(iface)) {
- log_info("ipc: ifup %s bridge (ports %s)", ifname, iface->bridge_ports ? iface->bridge_ports : "(none)");
+ log_info("ipc: ifup %s bridge (ports %s)", ifname,
+ iface->bridge_ports ? iface->bridge_ports : "(none)");
rtnl_bridge_create(ifname);
if (iface->bridge_stp) {
bool on = !strcasecmp(iface->bridge_stp, "on") || !strcasecmp(iface->bridge_stp, "yes");
@@ -356,18 +168,15 @@ static int handle_ifup(FILE *out, const char *ifname, uid_t uid) {
free(ports);
}
}
- // Bring link up
- // Enslave before the link comes up and addresses land: moving an interface
- // into a VRF flushes its addresses, so doing it afterwards would silently
- // discard what we just configured.
+
+ // Enslave before addresses land: entering a VRF flushes them.
if (iface->vrf_master) {
rtnl_vrf_add_port(iface->vrf_master, ifname);
}
if (rtnl_link_up(ifname) != 0) {
- fprintf(out, "ERR ifup %s: link up failed\n", ifname);
+ reply_err(c, "ifup %s: link up failed", ifname);
return -1;
}
- // Addresses
for (struct iface_addr *a = iface->addrs; a; a = a->next) {
log_info("ipc: ifup %s addr %s", ifname, a->address);
rtnl_addr_add(ifname, a->address, a->netmask);
@@ -376,48 +185,42 @@ static int handle_ifup(FILE *out, const char *ifname, uid_t uid) {
log_info("ipc: ifup %s gateway %s", ifname, iface->gateway);
rtnl_route_add_default(iface->gateway, ifname);
}
- if (iface->mtu) {
- if (rtnl_link_set_mtu(ifname, iface->mtu) != 0) {
- fprintf(out, "ERR ifup %s: mtu failed\n", ifname);
- return -1;
- }
+ if (iface->mtu && rtnl_link_set_mtu(ifname, iface->mtu) != 0) {
+ reply_err(c, "ifup %s: mtu failed", ifname);
+ return -1;
}
- if (iface->hwaddress) {
- if (rtnl_link_set_hwaddr(ifname, iface->hwaddress) != 0) {
- fprintf(out, "ERR ifup %s: hwaddress failed\n", ifname);
- return -1;
- }
+ if (iface->hwaddress && rtnl_link_set_hwaddr(ifname, iface->hwaddress) != 0) {
+ reply_err(c, "ifup %s: hwaddress failed", ifname);
+ return -1;
}
if (iface->post_up && run_hook(iface->post_up) != 0) {
- fprintf(out, "ERR post-up hook failed for %s\n", ifname);
+ reply_err(c, "post-up hook failed for %s", ifname);
return -1;
}
return 0;
}
-static int handle_ifdown(FILE *out, const char *ifname, uid_t uid) {
- (void)uid;
+static int do_ifdown(struct ipc_conn *c, const char *ifname) {
struct linkd_iface *iface = ifaces_find(ifname);
if (!iface) {
log_info("ipc: ifdown %s (not in interfaces, just bringing link down)", ifname);
if (rtnl_link_down(ifname) != 0) {
- fprintf(out, "ERR ifdown %s failed\n", ifname);
+ reply_err(c, "ifdown %s failed", ifname);
return -1;
}
return 0;
}
log_info("ipc: ifdown %s", ifname);
if (iface->pre_down && run_hook(iface->pre_down) != 0) {
- fprintf(out, "ERR pre-down hook failed for %s\n", ifname);
+ reply_err(c, "pre-down hook failed for %s", ifname);
return -1;
}
if (rtnl_link_down(ifname) != 0) {
- fprintf(out, "ERR ifdown %s failed\n", ifname);
+ reply_err(c, "ifdown %s failed", ifname);
return -1;
}
- // Flush addresses (keep for now)
if (iface->post_down && run_hook(iface->post_down) != 0) {
- fprintf(out, "ERR post-down hook failed for %s\n", ifname);
+ reply_err(c, "post-down hook failed for %s", ifname);
return -1;
}
return 0;
@@ -429,82 +232,503 @@ static int count_ifaces(void) {
return n;
}
-static int handle_ifreload(FILE *out, uid_t uid) {
- (void)uid;
- log_info("ipc: ifreload (diff-apply) start, old list has %d", count_ifaces());
- // For now, just re-parse and apply all auto interfaces
- // TODO: implement diff-apply: compare old vs new lists
+static int do_ifreload(struct ipc_conn *c) {
+ log_info("ipc: ifreload start, old list has %d", count_ifaces());
+ // TODO: real diff-apply; this frees and re-parses.
ifaces_free();
ports_free();
- log_info("ipc: ifreload after free, list has %d", count_ifaces());
- // Same loader startup uses. Was a private copy hardcoding /etc/network,
- // so --config was silently ignored here.
const struct linkd_daemon_cfg *dcfg = daemon_cfg();
if (load_namespace("interfaces", dcfg->iface, dcfg->iface_n) < 0) {
- ipc_send_err(out, "ifreload: failed to reload interface configuration");
+ reply_err(c, "ifreload: failed to reload interface configuration");
return -1;
}
if (load_namespace("ports", dcfg->ports, dcfg->ports_n) < 0) {
- ipc_send_err(out, "ifreload: failed to reload port configuration");
+ reply_err(c, "ifreload: failed to reload port configuration");
return -1;
}
- {
- int n = 0;
- for (struct linkd_iface *c = ifaces_list(); c; c = c->next) n++;
- log_info("ipc: ifreload done, new list has %d", n);
- for (struct linkd_iface *c = ifaces_list(); c; c = c->next) {
- log_info("ipc: ifreload iface %s", c->name);
+ log_info("ipc: ifreload done, new list has %d", count_ifaces());
+ return 0;
+}
+
+// Flat key/value pairs (RESP2 map). Keys may repeat: several addresses.
+static void iface_to_map(resp_object *m, const struct linkd_iface *i) {
+ char num[16];
+ resp_array_append_bulk(m, "name");
+ resp_array_append_bulk(m, i->name);
+ if (i->method) {
+ resp_array_append_bulk(m, "method");
+ resp_array_append_bulk(m, i->method);
+ }
+ resp_array_append_bulk(m, "auto");
+ resp_array_append_bulk(m, i->auto_flag ? "yes" : "no");
+ for (struct iface_addr *a = i->addrs; a; a = a->next) {
+ resp_array_append_bulk(m, "address");
+ resp_array_append_bulk(m, a->address);
+ if (a->netmask) {
+ resp_array_append_bulk(m, "netmask");
+ resp_array_append_bulk(m, a->netmask);
}
}
- return 0;
+ if (i->gateway) { resp_array_append_bulk(m, "gateway"); resp_array_append_bulk(m, i->gateway); }
+ if (i->mtu) { snprintf(num, sizeof(num), "%d", i->mtu);
+ resp_array_append_bulk(m, "mtu"); resp_array_append_bulk(m, num); }
+ if (i->hwaddress) { resp_array_append_bulk(m, "hwaddress"); resp_array_append_bulk(m, i->hwaddress); }
+ if (i->vlan_raw_device) { resp_array_append_bulk(m, "vlan-raw-device"); resp_array_append_bulk(m, i->vlan_raw_device); }
+ if (i->vlan_id >= 0) { snprintf(num, sizeof(num), "%d", i->vlan_id);
+ resp_array_append_bulk(m, "vlan-id"); resp_array_append_bulk(m, num); }
+ if (i->bridge_ports) { resp_array_append_bulk(m, "bridge-ports"); resp_array_append_bulk(m, i->bridge_ports); }
+ if (i->bridge_stp) { resp_array_append_bulk(m, "bridge-stp"); resp_array_append_bulk(m, i->bridge_stp); }
+ if (i->bridge_vlan_aware >= 0) {
+ resp_array_append_bulk(m, "bridge-vlan-aware");
+ resp_array_append_bulk(m, i->bridge_vlan_aware ? "yes" : "no");
+ }
+ if (i->vrf_table >= 0) { snprintf(num, sizeof(num), "%d", i->vrf_table);
+ resp_array_append_bulk(m, "vrf-table"); resp_array_append_bulk(m, num); }
+ if (i->vrf_master) { resp_array_append_bulk(m, "vrf"); resp_array_append_bulk(m, i->vrf_master); }
+ if (i->pre_up) { resp_array_append_bulk(m, "pre-up"); resp_array_append_bulk(m, i->pre_up); }
+ if (i->post_up) { resp_array_append_bulk(m, "post-up"); resp_array_append_bulk(m, i->post_up); }
+ if (i->pre_down) { resp_array_append_bulk(m, "pre-down"); resp_array_append_bulk(m, i->pre_down); }
+ if (i->post_down) { resp_array_append_bulk(m, "post-down"); resp_array_append_bulk(m, i->post_down); }
}
-static int handle_ifquery(FILE *out, const char *ifname) {
- struct linkd_iface *cur;
- int n = 0;
- for (cur = ifaces_list(); cur; cur = cur->next) n++;
- log_info("ipc: ifquery %s (list has %d)", ifname ? ifname : "(all)", n);
+static int do_ifquery(struct ipc_conn *c, const char *ifname) {
if (ifname) {
- cur = ifaces_find(ifname);
- log_info("ipc: ifquery find %s -> %s", ifname, cur ? "found" : "not found");
- if (!cur) {
- fprintf(out, "ERR no such interface: %s\n", ifname);
+ struct linkd_iface *i = ifaces_find(ifname);
+ if (!i) {
+ reply_err(c, "no such interface: %s", ifname);
return -1;
}
- // Dump this iface in cnf style
- fprintf(out, "iface %s\n", cur->name);
- for (struct iface_addr *a = cur->addrs; a; a = a->next) {
- fprintf(out, " address %s\n", a->address);
- if (a->netmask) fprintf(out, " netmask %s\n", a->netmask);
+ resp_object *m = resp_array_init();
+ iface_to_map(m, i);
+ reply_obj(c, m);
+ return 0;
+ }
+
+ resp_object *outer = resp_array_init();
+ for (struct linkd_iface *i = ifaces_list(); i; i = i->next) {
+ resp_object *m = resp_array_init();
+ iface_to_map(m, i);
+ resp_array_append_obj(outer, m);
+ }
+ reply_obj(c, outer);
+ return 0;
+}
+
+struct command {
+ const char *name;
+ int min_args; // including the verb
+ int max_args; // -1 = unlimited
+ int role; // minimum role
+};
+
+static const struct command commands[] = {
+ { "PING", 1, 2, IPC_ROLE_NONE },
+ { "INFO", 1, 1, IPC_ROLE_READONLY },
+ { "COMMAND", 1, 2, IPC_ROLE_READONLY },
+ { "IFQUERY", 1, 2, IPC_ROLE_READONLY },
+ { "IFUP", 2, 2, IPC_ROLE_FULL },
+ { "IFDOWN", 2, 2, IPC_ROLE_FULL },
+ { "IFRELOAD", 1, 1, IPC_ROLE_FULL },
+ { "AUTH", 2, 3, IPC_ROLE_NONE },
+ { "QUIT", 1, 1, IPC_ROLE_NONE },
+};
+static const size_t commands_n = sizeof(commands) / sizeof(commands[0]);
+
+static const char *arg(const resp_object *cmd, size_t i) {
+ if (!cmd || cmd->type != RESPT_ARRAY || i >= cmd->u.arr.n) return NULL;
+ const resp_object *e = &cmd->u.arr.elem[i];
+ if (e->type != RESPT_BULK && e->type != RESPT_SIMPLE) return NULL;
+ return e->u.s;
+}
+
+static void do_info(struct ipc_conn *c) {
+ char buf[512];
+ snprintf(buf, sizeof(buf),
+ "# Server\r\n"
+ "name:linkd\r\n"
+ "version:" __TARGET "\r\n"
+ "\r\n"
+ "# Clients\r\n"
+ "connected_clients:%d\r\n"
+ "\r\n"
+ "# Config\r\n"
+ "interfaces:%d\r\n",
+ conn_n, count_ifaces());
+ resp_object *o = calloc(1, sizeof(*o));
+ if (!o) return;
+ o->type = RESPT_BULK;
+ o->u.s = strdup(buf);
+ reply_obj(c, o);
+}
+
+static void do_command_list(struct ipc_conn *c) {
+ resp_object *a = resp_array_init();
+ for (size_t i = 0; i < commands_n; i++) {
+ resp_object *e = resp_array_init();
+ resp_array_append_bulk(e, commands[i].name);
+ resp_array_append_int(e, commands[i].max_args < 0
+ ? -commands[i].min_args : commands[i].max_args);
+ resp_array_append_obj(a, e);
+ }
+ reply_obj(c, a);
+}
+
+static void do_auth(struct ipc_conn *c, const resp_object *cmd) {
+ const struct linkd_daemon_cfg *cfg = daemon_cfg();
+ if (!cfg->authfile) {
+ reply_err(c, "Client sent AUTH, but no password file is configured");
+ return;
+ }
+
+ const char *user = arg(cmd, 1);
+ const char *pass = arg(cmd, 2);
+ if (!pass) { pass = user; user = "default"; }
+ if (!user || !pass) {
+ reply_err(c, "wrong number of arguments for 'AUTH'");
+ return;
+ }
+
+ int role = auth_check(cfg->authfile, user, pass);
+ if (role == AUTH_ROLE_NONE) {
+ log_warn("ipc: failed AUTH for user '%s'", user);
+ reply_err(c, "WRONGPASS invalid username-password pair");
+ return;
+ }
+
+ c->role = (role == AUTH_ROLE_FULL) ? IPC_ROLE_FULL : IPC_ROLE_READONLY;
+ log_info("ipc: user '%s' authenticated (%s)", user,
+ c->role == IPC_ROLE_FULL ? "full" : "readonly");
+ reply_ok(c);
+}
+
+static void dispatch(struct ipc_conn *c, const resp_object *cmd) {
+ const char *verb = arg(cmd, 0);
+ if (!verb) {
+ reply_err(c, "ERR malformed command");
+ return;
+ }
+
+ size_t argc = (cmd->type == RESPT_ARRAY) ? cmd->u.arr.n : 0;
+
+ const struct command *spec = NULL;
+ for (size_t i = 0; i < commands_n; i++) {
+ if (!strcasecmp(verb, commands[i].name)) { spec = &commands[i]; break; }
+ }
+ if (!spec) {
+ reply_err(c, "unknown command '%s'", verb);
+ return;
+ }
+
+ if ((int)argc < spec->min_args ||
+ (spec->max_args >= 0 && (int)argc > spec->max_args)) {
+ reply_err(c, "wrong number of arguments for '%s'", verb);
+ return;
+ }
+
+ if (c->role < spec->role) {
+ reply_err(c, c->role == IPC_ROLE_NONE
+ ? "NOAUTH Authentication required"
+ : "NOPERM this command requires full access");
+ return;
+ }
+
+ if (!strcasecmp(verb, "PING")) {
+ const char *msg = arg(cmd, 1);
+ if (msg) {
+ resp_object *o = calloc(1, sizeof(*o));
+ if (o) { o->type = RESPT_BULK; o->u.s = strdup(msg); reply_obj(c, o); }
+ } else {
+ reply_obj(c, resp_simple_init("PONG"));
+ }
+ } else if (!strcasecmp(verb, "QUIT")) {
+ reply_ok(c);
+ c->close_after_write = 1;
+ } else if (!strcasecmp(verb, "INFO")) {
+ do_info(c);
+ } else if (!strcasecmp(verb, "COMMAND")) {
+ do_command_list(c);
+ } else if (!strcasecmp(verb, "AUTH")) {
+ do_auth(c, cmd);
+ } else if (!strcasecmp(verb, "IFQUERY")) {
+ do_ifquery(c, arg(cmd, 1));
+ } else if (!strcasecmp(verb, "IFUP")) {
+ if (do_ifup(c, arg(cmd, 1)) == 0) reply_ok(c);
+ } else if (!strcasecmp(verb, "IFDOWN")) {
+ if (do_ifdown(c, arg(cmd, 1)) == 0) reply_ok(c);
+ } else if (!strcasecmp(verb, "IFRELOAD")) {
+ if (do_ifreload(c) == 0) reply_ok(c);
+ }
+}
+
+static void conn_close(struct ipc_conn *c) {
+ struct ipc_conn **p = &conns;
+ while (*p && *p != c) p = &(*p)->next;
+ if (*p) *p = c->next;
+ if (c->fd >= 0) close(c->fd);
+ free(c->rbuf);
+ free(c->wbuf);
+ free(c);
+ conn_n--;
+}
+
+static void conn_accept(struct listener *l) {
+ int fd = accept4(l->fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
+ if (fd < 0) return;
+
+ if (conn_n >= IPC_MAX_CONNS) {
+ log_warn("ipc: connection limit reached, rejecting");
+ close(fd);
+ return;
+ }
+
+ struct ipc_conn *c = calloc(1, sizeof(*c));
+ if (!c) { close(fd); return; }
+ c->fd = fd;
+ c->is_unix = l->is_unix;
+ c->role = IPC_ROLE_NONE;
+
+ // Root over unix is pre-authenticated; the kernel vouches for the uid.
+ if (l->is_unix) {
+ struct ucred cred;
+ socklen_t len = sizeof(cred);
+ if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) == 0) {
+ c->uid = cred.uid;
+ if (cred.uid == 0) c->role = IPC_ROLE_FULL;
+ }
+ }
+
+ c->next = conns;
+ conns = c;
+ conn_n++;
+}
+
+static void conn_read(struct ipc_conn *c) {
+ char buf[4096];
+ for (;;) {
+ ssize_t n = read(c->fd, buf, sizeof(buf));
+ if (n == 0) { conn_close(c); return; }
+ if (n < 0) {
+ if (errno == EINTR) continue;
+ if (errno == EAGAIN || errno == EWOULDBLOCK) break;
+ conn_close(c);
+ return;
+ }
+ if (c->rlen + (size_t)n > RBUF_MAX) {
+ log_warn("ipc: request too large, dropping client");
+ conn_close(c);
+ return;
+ }
+ if (c->rlen + (size_t)n > c->rcap) {
+ size_t cap = c->rcap ? c->rcap : 4096;
+ while (cap < c->rlen + (size_t)n) cap *= 2;
+ char *g = realloc(c->rbuf, cap);
+ if (!g) { conn_close(c); return; }
+ c->rbuf = g;
+ c->rcap = cap;
+ }
+ memcpy(c->rbuf + c->rlen, buf, (size_t)n);
+ c->rlen += (size_t)n;
+ if ((size_t)n < sizeof(buf)) break;
+ }
+
+ // Drain every complete command; this is what makes pipelining work.
+ for (;;) {
+ resp_object *cmd = NULL;
+ int used = resp_read_buf(c->rbuf, c->rlen, &cmd);
+ if (used <= 0) {
+ // 0 = nothing yet, <0 = partial; wait for more data.
+ if (cmd) resp_free(cmd);
+ break;
+ }
+ dispatch(c, cmd);
+ resp_free(cmd);
+
+ memmove(c->rbuf, c->rbuf + used, c->rlen - (size_t)used);
+ c->rlen -= (size_t)used;
+ if (c->rlen == 0) break;
+ }
+}
+
+static void conn_write(struct ipc_conn *c) {
+ while (c->wsent < c->wlen) {
+ ssize_t n = write(c->fd, c->wbuf + c->wsent, c->wlen - c->wsent);
+ if (n < 0) {
+ if (errno == EINTR) continue;
+ if (errno == EAGAIN || errno == EWOULDBLOCK) return;
+ conn_close(c);
+ return;
+ }
+ c->wsent += (size_t)n;
+ }
+ c->wlen = c->wsent = 0;
+ if (c->close_after_write) conn_close(c);
+}
+
+static int listen_unix(const char *path) {
+ struct sockaddr_un addr;
+
+ {
+ char *d = strdup(path);
+ if (d) { mkdir(dirname(d), 0755); free(d); }
+ }
+ unlink(path);
+
+ int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
+ if (fd < 0) {
+ log_error("ipc: socket: %s", strerror(errno));
+ return -1;
+ }
+
+ memset(&addr, 0, sizeof(addr));
+ addr.sun_family = AF_UNIX;
+ strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1);
+
+ if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
+ log_error("ipc: bind %s: %s", path, strerror(errno));
+ close(fd);
+ return -1;
+ }
+ if (chmod(path, 0600) != 0) {
+ log_warn("ipc: chmod %s: %s", path, strerror(errno));
+ }
+ if (listen(fd, 8) != 0) {
+ log_error("ipc: listen %s: %s", path, strerror(errno));
+ close(fd);
+ unlink(path);
+ return -1;
+ }
+ return fd;
+}
+
+static int listen_tcp(const char *hostport) {
+ char *copy = strdup(hostport);
+ if (!copy) return -1;
+
+ char *host = copy;
+ char *port = strrchr(copy, ':');
+ if (port) { *port++ = '\0'; } else { port = (char *)"6789"; }
+ if (*host == '\0') host = NULL;
+
+ struct addrinfo hints = {
+ .ai_family = AF_UNSPEC,
+ .ai_socktype = SOCK_STREAM,
+ .ai_flags = AI_PASSIVE,
+ };
+ struct addrinfo *res = NULL;
+ int gai = getaddrinfo(host, port, &hints, &res);
+ if (gai != 0) {
+ log_error("ipc: resolve %s: %s", hostport, gai_strerror(gai));
+ free(copy);
+ return -1;
+ }
+
+ int fd = -1;
+ for (struct addrinfo *ai = res; ai; ai = ai->ai_next) {
+ fd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC | SOCK_NONBLOCK, ai->ai_protocol);
+ if (fd < 0) continue;
+ int one = 1;
+ setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+ if (bind(fd, ai->ai_addr, ai->ai_addrlen) == 0 && listen(fd, 8) == 0) break;
+ close(fd);
+ fd = -1;
+ }
+ freeaddrinfo(res);
+ free(copy);
+
+ if (fd < 0) log_error("ipc: cannot listen on %s", hostport);
+ return fd;
+}
+
+int ipc_init(void) {
+ const struct linkd_daemon_cfg *cfg = daemon_cfg();
+ int ok = 0;
+
+ for (size_t i = 0; i < cfg->listen_n && listener_n < IPC_MAX_LISTENERS; i++) {
+ const char *a = cfg->listen[i];
+ int is_unix = !strncmp(a, "unix://", 7);
+ int fd;
+
+ if (is_unix) {
+ fd = listen_unix(a + 7);
+ } else {
+ // No way to authorise a tcp client without one; SO_PEERCRED is
+ // unix-only.
+ if (!cfg->authfile) {
+ log_error("ipc: %s needs `authfile` configured; refusing to listen", a);
+ continue;
+ }
+ fd = listen_tcp(a + 6);
+ }
+ if (fd < 0) continue;
+
+ listeners[listener_n].fd = fd;
+ listeners[listener_n].is_unix = is_unix;
+ listeners[listener_n].path = is_unix ? strdup(a + 7) : NULL;
+ listener_n++;
+ ok++;
+ log_info("ipc: listening on %s", a);
+ }
+
+ return ok ? 0 : -1;
+}
+
+void ipc_fini(void) {
+ while (conns) conn_close(conns);
+ for (int i = 0; i < listener_n; i++) {
+ if (listeners[i].fd >= 0) close(listeners[i].fd);
+ if (listeners[i].path) {
+ unlink(listeners[i].path);
+ free(listeners[i].path);
}
- if (cur->gateway) fprintf(out, " gateway %s\n", cur->gateway);
- if (cur->mtu) fprintf(out, " mtu %d\n", cur->mtu);
- if (cur->hwaddress) fprintf(out, " hwaddress %s\n", cur->hwaddress);
- if (cur->vlan_raw_device) fprintf(out, " vlan-raw-device %s\n", cur->vlan_raw_device);
- if (cur->vlan_id >= 0) fprintf(out, " vlan-id %d\n", cur->vlan_id);
- if (cur->bridge_ports) fprintf(out, " bridge-ports %s\n", cur->bridge_ports);
- if (cur->bridge_stp) fprintf(out, " bridge-stp %s\n", cur->bridge_stp);
- if (cur->bridge_vlan_aware >= 0)
- fprintf(out, " bridge-vlan-aware %s\n", cur->bridge_vlan_aware ? "yes" : "no");
- if (cur->vrf_table >= 0) fprintf(out, " vrf-table %d\n", cur->vrf_table);
- if (cur->vrf_master) fprintf(out, " vrf %s\n", cur->vrf_master);
- if (cur->pre_up) fprintf(out, " pre-up %s\n", cur->pre_up);
- if (cur->post_up) fprintf(out, " post-up %s\n", cur->post_up);
- if (cur->pre_down) fprintf(out, " pre-down %s\n", cur->pre_down);
- if (cur->post_down) fprintf(out, " post-down %s\n", cur->post_down);
- fprintf(out, "OK\n");
- } else {
- for (cur = ifaces_list(); cur; cur = cur->next) {
- fprintf(out, "iface %s", cur->name);
- if (cur->method) fprintf(out, " %s", cur->method);
- fprintf(out, "\n");
- for (struct iface_addr *a = cur->addrs; a; a = a->next) {
- fprintf(out, " address %s\n", a->address);
+ }
+ listener_n = 0;
+}
+
+int ipc_pollfds(struct pollfd *pfds, int max) {
+ int n = 0;
+ for (int i = 0; i < listener_n && n < max; i++) {
+ pfds[n].fd = listeners[i].fd;
+ pfds[n].events = POLLIN;
+ pfds[n].revents = 0;
+ n++;
+ }
+ for (struct ipc_conn *c = conns; c && n < max; c = c->next) {
+ pfds[n].fd = c->fd;
+ pfds[n].events = POLLIN | (c->wsent < c->wlen ? POLLOUT : 0);
+ pfds[n].revents = 0;
+ n++;
+ }
+ return n;
+}
+
+void ipc_dispatch(struct pollfd *pfds, int n) {
+ for (int i = 0; i < n; i++) {
+ if (!pfds[i].revents) continue;
+
+ int is_listener = 0;
+ for (int l = 0; l < listener_n; l++) {
+ if (listeners[l].fd == pfds[i].fd) {
+ conn_accept(&listeners[l]);
+ is_listener = 1;
+ break;
}
- if (cur->gateway) fprintf(out, " gateway %s\n", cur->gateway);
}
- fprintf(out, "OK\n");
+ if (is_listener) continue;
+
+ // Look up by fd: an earlier fd may have closed and freed this one.
+ struct ipc_conn *c = conns;
+ while (c && c->fd != pfds[i].fd) c = c->next;
+ if (!c) continue;
+
+ if (pfds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) { conn_close(c); continue; }
+ if (pfds[i].revents & POLLIN) conn_read(c);
+
+ // conn_read may have closed it; re-find before writing.
+ struct ipc_conn *still = conns;
+ while (still && still != c) still = still->next;
+ if (!still) continue;
+
+ if (c->wsent < c->wlen) conn_write(c);
}
- return 0;
}
diff --git a/src/ipc.h b/src/ipc.h
@@ -1,12 +1,27 @@
#ifndef __LINKD_IPC_H__
#define __LINKD_IPC_H__
+#include <poll.h>
-// Server side (linkd)
-int ipc_init(void);
-int ipc_fd(void);
-int ipc_handle(void); // accept and handle one client, returns 0 on handled, -1 on error
+// RESP server. Speaks the Redis wire protocol over any number of unix and tcp
+// listeners, as configured by `listen` in /etc/linkd.cnf.
+
+#define IPC_ROLE_NONE 0 // connected, not authenticated
+#define IPC_ROLE_READONLY 1 // IFQUERY, INFO, COMMAND, PING
+#define IPC_ROLE_FULL 2 // + IFUP, IFDOWN, IFRELOAD
+
+// Upper bound on fds this module contributes to the poll set.
+#define IPC_MAX_LISTENERS 8
+#define IPC_MAX_CONNS 64
+#define IPC_MAX_POLLFDS (IPC_MAX_LISTENERS + IPC_MAX_CONNS)
+
+int ipc_init(void);
void ipc_fini(void);
-void ipc_register(void);
+
+// Fill `pfds` with listeners and live connections. Returns the count.
+int ipc_pollfds(struct pollfd *pfds, int max);
+
+// Service whatever `ipc_pollfds` filled in, after poll() has run.
+void ipc_dispatch(struct pollfd *pfds, int n);
#endif // __LINKD_IPC_H__
diff --git a/src/main.c b/src/main.c
@@ -7,45 +7,29 @@
#include "cli/registry.h"
// Multicall: basename(argv[0]) selects which main runs. Commands self-register
-// from constructors in src/cli/. linkctl is the exception -- it takes its
-// subcommand from argv[1].
+// from constructors in src/cli/.
static const char *AVAILABLE = "linkd, linkctl, ifup, ifdown, ifquery, ifreload";
-// Shift argv left by one so the callee sees its own name in argv[0].
-static int dispatch_shifted(cli_main_fn fn, int argc, char *argv[]) {
- char **shifted = malloc(sizeof(char *) * (size_t)argc);
- if (!shifted) {
- fprintf(stderr, "out of memory\n");
- return 1;
- }
- shifted[0] = argv[1];
- for (int i = 2; i < argc; i++) shifted[i - 1] = argv[i];
- int rc = fn(argc - 1, shifted);
- free(shifted);
- return rc;
-}
-
int main(int argc, char *argv[]) {
const char *prog = basename(argv[0]);
- // linkctl <command> [args...]
- if (!strcmp(prog, "linkctl") && argc > 1) {
- cli_main_fn fn = cli_find(argv[1]);
- if (!fn) {
- fprintf(stderr, "linkctl: unknown command: %s\n", argv[1]);
- fprintf(stderr, "available: %s\n", AVAILABLE);
- return 1;
- }
- return dispatch_shifted(fn, argc, argv);
- }
-
cli_main_fn fn = cli_find(prog);
if (fn) return fn(argc, argv);
- // Unrecognised name (build tree, wrapper script): try argv[1].
+ // Unrecognised name (build tree, wrapper script): try argv[1], shifting so
+ // the callee sees its own name in argv[0].
if (argc > 1 && (fn = cli_find(argv[1]))) {
- return dispatch_shifted(fn, argc, argv);
+ char **shifted = malloc(sizeof(char *) * (size_t)argc);
+ if (!shifted) {
+ fprintf(stderr, "out of memory\n");
+ return 1;
+ }
+ shifted[0] = argv[1];
+ for (int i = 2; i < argc; i++) shifted[i - 1] = argv[i];
+ int rc = fn(argc - 1, shifted);
+ free(shifted);
+ return rc;
}
fprintf(stderr, "%s: unknown command: %s\n", argv[0], prog);
diff --git a/src/netlink/netlink.c b/src/netlink/netlink.c
@@ -24,7 +24,7 @@
#include "rxi/log.h"
#include "dataplane.h"
-#include "dataplane/registry.h"
+#include "dataplane/plugin.h"
#include "filter.h"
#include "netlink.h"
@@ -175,7 +175,6 @@ static int handle_link(struct nlmsghdr *nh) {
int master = 0;
int is_vrf = 0;
uint32_t vrf_table = 0;
- const struct dp_ops *dp;
int len;
len = (int)(nh->nlmsg_len - NLMSG_LENGTH(sizeof(*ifi)));
@@ -216,11 +215,8 @@ static int handle_link(struct nlmsghdr *nh) {
vrf_master_remove(ifi->ifi_index);
log_info("netlink: vrf %s removed", ifname);
}
- dp = dp_active();
- if (dp && dp->port_admin) {
- if (dp->port_admin(ifname, false) != DP_RET_OK) {
- log_error("netlink: port_admin %s down failed", ifname);
- }
+ if (dp_port_admin(ifname, false) != DP_RET_OK) {
+ log_error("netlink: port_admin %s down failed", ifname);
}
return 0;
}
@@ -237,18 +233,12 @@ static int handle_link(struct nlmsghdr *nh) {
}
}
- dp = dp_active();
- if (!dp) return 0;
log_debug("netlink: link %s %s mtu %u", ifname, (ifi->ifi_flags & IFF_UP) ? "up" : "down", mtu);
- if (dp->port_admin) {
- if (dp->port_admin(ifname, (ifi->ifi_flags & IFF_UP) ? true : false) != DP_RET_OK) {
- log_error("netlink: port_admin %s failed", ifname);
- }
+ if (dp_port_admin(ifname, (ifi->ifi_flags & IFF_UP) ? true : false) != DP_RET_OK) {
+ log_error("netlink: port_admin %s failed", ifname);
}
- if (mtu && dp->port_mtu) {
- if (dp->port_mtu(ifname, mtu) != DP_RET_OK) {
- log_error("netlink: port_mtu %s %u failed", ifname, mtu);
- }
+ if (mtu && dp_port_mtu(ifname, mtu) != DP_RET_OK) {
+ log_error("netlink: port_mtu %s %u failed", ifname, mtu);
}
return 0;
}
@@ -259,7 +249,6 @@ static int handle_addr(struct nlmsghdr *nh) {
char ifname[IF_NAMESIZE] = "";
struct dp_rif rif;
struct dp_addr addr;
- const struct dp_ops *dp;
void *bytes = NULL;
size_t want = 0;
int len;
@@ -293,14 +282,12 @@ static int handle_addr(struct nlmsghdr *nh) {
return 0;
}
- dp = dp_active();
- if (!dp) return 0;
del = (nh->nlmsg_type == RTM_DELADDR);
log_debug("netlink: rif %s %s table %u", ifname, del ? "del" : "add", rif.table);
if (del) {
- if (dp->rif_del && dp->rif_del(&rif) != DP_RET_OK) log_error("netlink: rif_del %s failed", ifname);
+ if (dp_rif_del(&rif) != DP_RET_OK) log_error("netlink: rif_del %s failed", ifname);
} else {
- if (dp->rif_add && dp->rif_add(&rif) != DP_RET_OK) log_error("netlink: rif_add %s failed", ifname);
+ if (dp_rif_add(&rif) != DP_RET_OK) log_error("netlink: rif_add %s failed", ifname);
}
return 0;
}
@@ -310,7 +297,6 @@ static int handle_neigh(struct nlmsghdr *nh) {
struct rtattr *tb[NDA_MAX + 1];
char ifname[IF_NAMESIZE] = "";
struct dp_neigh neigh;
- const struct dp_ops *dp;
int len;
int del;
@@ -346,13 +332,11 @@ static int handle_neigh(struct nlmsghdr *nh) {
return 0;
}
- dp = dp_active();
- if (!dp) return 0;
log_debug("netlink: neigh %s %s table %u", ifname, del ? "del" : "add", neigh.table);
if (del) {
- if (dp->neigh_del && dp->neigh_del(&neigh) != DP_RET_OK) log_error("netlink: neigh_del failed");
+ if (dp_neigh_del(&neigh) != DP_RET_OK) log_error("netlink: neigh_del failed");
} else {
- if (dp->neigh_add && dp->neigh_add(&neigh) != DP_RET_OK) log_error("netlink: neigh_add failed");
+ if (dp_neigh_add(&neigh) != DP_RET_OK) log_error("netlink: neigh_add failed");
}
return 0;
}
@@ -363,7 +347,6 @@ static int handle_route(struct nlmsghdr *nh) {
struct dp_route route;
struct dp_nexthop nhops[NL_MAX_NH];
char nhnames[NL_MAX_NH][IF_NAMESIZE];
- const struct dp_ops *dp;
uint32_t table;
uint32_t metric = 0;
int len;
@@ -441,14 +424,12 @@ static int handle_route(struct nlmsghdr *nh) {
route.nh = nh_count ? nhops : NULL;
route.nh_count = nh_count;
- dp = dp_active();
- if (!dp) return 0;
del = (nh->nlmsg_type == RTM_DELROUTE);
log_debug("netlink: route %s table %u nh %zu", del ? "del" : "add", table, nh_count);
if (del) {
- if (dp->route_del && dp->route_del(&route) != DP_RET_OK) log_error("netlink: route_del table %u failed", table);
+ if (dp_route_del(&route) != DP_RET_OK) log_error("netlink: route_del table %u failed", table);
} else {
- if (dp->route_add && dp->route_add(&route) != DP_RET_OK) log_error("netlink: route_add table %u failed", table);
+ if (dp_route_add(&route) != DP_RET_OK) log_error("netlink: route_add table %u failed", table);
}
return 0;
}
@@ -570,7 +551,6 @@ int nl_dump(int fd, uint16_t type, int family) {
// Full converge: VRF map first (routes resolve against it), then RIFs,
// neighbours, routes, then the backend's own resync backstop.
int nl_resync(int fd) {
- const struct dp_ops *dp;
int fails = 0;
nl_filter_reset();
@@ -584,9 +564,8 @@ int nl_resync(int fd) {
if (nl_dump(fd, RTM_GETROUTE, AF_INET) != 0) fails++;
if (nl_dump(fd, RTM_GETROUTE, AF_INET6) != 0) fails++;
- dp = dp_active();
- if (dp && dp->resync && dp->resync() != DP_RET_OK) {
- log_error("netlink: backend resync failed");
+ if (dp_resync() != DP_RET_OK) {
+ log_error("netlink: plugin resync failed");
fails++;
}
if (fails) {
@@ -607,7 +586,9 @@ int nl_run(int fd, int resync_interval_s) {
clock_gettime(CLOCK_MONOTONIC, &last_resync);
while (!nl_stop) {
- struct pollfd pfds[2];
+ // Netlink occupies slot 0; the IPC server contributes its listeners and
+ // every live connection after it.
+ struct pollfd pfds[1 + IPC_MAX_POLLFDS];
int nfds = 1;
struct timespec now;
long wait_ms = 30000;
@@ -615,12 +596,8 @@ int nl_run(int fd, int resync_interval_s) {
pfds[0].fd = fd;
pfds[0].events = POLLIN;
- int ipcfd = ipc_fd();
- if (ipcfd >= 0) {
- pfds[1].fd = ipcfd;
- pfds[1].events = POLLIN;
- nfds = 2;
- }
+ pfds[0].revents = 0;
+ nfds += ipc_pollfds(&pfds[1], IPC_MAX_POLLFDS);
clock_gettime(CLOCK_MONOTONIC, &now);
if (resync_interval_s > 0) {
@@ -646,9 +623,7 @@ int nl_run(int fd, int resync_interval_s) {
}
if (!pr) continue;
- if (nfds > 1 && (pfds[1].revents & POLLIN)) {
- ipc_handle();
- }
+ if (nfds > 1) ipc_dispatch(&pfds[1], nfds - 1);
if (!(pfds[0].revents & POLLIN)) continue;
for (;;) {
diff --git a/src/util/auth.c b/src/util/auth.c
@@ -0,0 +1,187 @@
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#include "finwo/pbkdf2.h"
+#include "rxi/log.h"
+
+#include "util/auth.h"
+
+#define HASH_LEN 32
+#define SALT_LEN 16
+
+static const char AB64[] =
+ "./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+
+int ab64_encode(const unsigned char *in, size_t len, char *out, size_t out_sz) {
+ size_t need = (len * 8 + 5) / 6;
+ if (out_sz < need + 1) return -1;
+
+ size_t o = 0;
+ for (size_t i = 0; i < len; i += 3) {
+ unsigned v = (unsigned)in[i] << 16;
+ if (i + 1 < len) v |= (unsigned)in[i + 1] << 8;
+ if (i + 2 < len) v |= (unsigned)in[i + 2];
+
+ out[o++] = AB64[(v >> 18) & 0x3f];
+ out[o++] = AB64[(v >> 12) & 0x3f];
+ if (i + 1 < len) out[o++] = AB64[(v >> 6) & 0x3f];
+ if (i + 2 < len) out[o++] = AB64[v & 0x3f];
+ }
+ out[o] = '\0';
+ return 0;
+}
+
+static int ab64_val(char c) {
+ const char *p = strchr(AB64, c);
+ return (p && c) ? (int)(p - AB64) : -1;
+}
+
+size_t ab64_decode(const char *in, unsigned char *out, size_t out_sz) {
+ size_t len = strlen(in);
+ size_t o = 0;
+
+ for (size_t i = 0; i < len; i += 4) {
+ int c0 = ab64_val(in[i]);
+ int c1 = (i + 1 < len) ? ab64_val(in[i + 1]) : -1;
+ int c2 = (i + 2 < len) ? ab64_val(in[i + 2]) : -1;
+ int c3 = (i + 3 < len) ? ab64_val(in[i + 3]) : -1;
+ if (c0 < 0 || c1 < 0) break;
+
+ unsigned v = ((unsigned)c0 << 18) | ((unsigned)c1 << 12);
+ if (c2 >= 0) v |= (unsigned)c2 << 6;
+ if (c3 >= 0) v |= (unsigned)c3;
+
+ if (o < out_sz) out[o++] = (unsigned char)((v >> 16) & 0xff);
+ if (c2 >= 0 && o < out_sz) out[o++] = (unsigned char)((v >> 8) & 0xff);
+ if (c3 >= 0 && o < out_sz) out[o++] = (unsigned char)(v & 0xff);
+ }
+ return o;
+}
+
+// Comparison time must not depend on how much of the hash matched.
+static int ct_equal(const unsigned char *a, const unsigned char *b, size_t n) {
+ unsigned char diff = 0;
+ for (size_t i = 0; i < n; i++) diff |= (unsigned char)(a[i] ^ b[i]);
+ return diff == 0;
+}
+
+int auth_verify_hash(const char *stored, const char *pass) {
+ if (!stored || !pass) return 0;
+
+ const char *prefix = "$pbkdf2-sha256$";
+ if (strncmp(stored, prefix, strlen(prefix)) != 0) return 0;
+
+ char *copy = strdup(stored + strlen(prefix));
+ if (!copy) return 0;
+
+ char *iter_s = copy;
+ char *salt_s = strchr(iter_s, '$');
+ if (!salt_s) { free(copy); return 0; }
+ *salt_s++ = '\0';
+ char *hash_s = strchr(salt_s, '$');
+ if (!hash_s) { free(copy); return 0; }
+ *hash_s++ = '\0';
+
+ char *end = NULL;
+ unsigned long iterations = strtoul(iter_s, &end, 10);
+ if (!end || *end || iterations == 0) { free(copy); return 0; }
+
+ unsigned char salt[64], want[HASH_LEN], got[HASH_LEN];
+ size_t salt_len = ab64_decode(salt_s, salt, sizeof(salt));
+ size_t want_len = ab64_decode(hash_s, want, sizeof(want));
+ if (salt_len == 0 || want_len != HASH_LEN) { free(copy); return 0; }
+
+ pbkdf2((const uint8_t *)pass, strlen(pass), salt, salt_len,
+ iterations, PBKDF2_SHA256, got, HASH_LEN);
+
+ int ok = ct_equal(got, want, HASH_LEN);
+ free(copy);
+ return ok;
+}
+
+char *auth_make_hash(const char *pass, unsigned iterations) {
+ if (!pass) return NULL;
+ if (!iterations) iterations = AUTH_DEFAULT_ITERATIONS;
+
+ unsigned char salt[SALT_LEN];
+ FILE *rnd = fopen("/dev/urandom", "rb");
+ if (!rnd) return NULL;
+ size_t got = fread(salt, 1, sizeof(salt), rnd);
+ fclose(rnd);
+ if (got != sizeof(salt)) return NULL;
+
+ unsigned char hash[HASH_LEN];
+ pbkdf2((const uint8_t *)pass, strlen(pass), salt, sizeof(salt),
+ iterations, PBKDF2_SHA256, hash, sizeof(hash));
+
+ char salt_b[64], hash_b[64];
+ if (ab64_encode(salt, sizeof(salt), salt_b, sizeof(salt_b)) != 0) return NULL;
+ if (ab64_encode(hash, sizeof(hash), hash_b, sizeof(hash_b)) != 0) return NULL;
+
+ char *out = NULL;
+ if (asprintf(&out, "$pbkdf2-sha256$%u$%s$%s", iterations, salt_b, hash_b) < 0) {
+ return NULL;
+ }
+ return out;
+}
+
+int auth_check_permissions(const char *path) {
+ struct stat st;
+ if (stat(path, &st) != 0) return -1;
+
+ if (st.st_mode & S_IWOTH) {
+ log_error("auth: %s is world-writable; refusing to use it", path);
+ return -1;
+ }
+ if (st.st_mode & (S_IRGRP | S_IROTH)) {
+ log_warn("auth: %s is readable beyond its owner", path);
+ }
+ return 0;
+}
+
+static int role_from_string(const char *s) {
+ if (!s || !*s) return AUTH_ROLE_READONLY;
+ if (!strcasecmp(s, "full")) return AUTH_ROLE_FULL;
+ if (!strcasecmp(s, "readonly")) return AUTH_ROLE_READONLY;
+ log_warn("auth: unknown role `%s', treating as readonly", s);
+ return AUTH_ROLE_READONLY;
+}
+
+int auth_check(const char *path, const char *user, const char *pass) {
+ if (!path || !user || !pass) return AUTH_ROLE_NONE;
+ if (auth_check_permissions(path) != 0) return AUTH_ROLE_NONE;
+
+ FILE *fd = fopen(path, "r");
+ if (!fd) {
+ log_error("auth: cannot open %s", path);
+ return AUTH_ROLE_NONE;
+ }
+
+ char line[1024];
+ int role = AUTH_ROLE_NONE;
+
+ while (fgets(line, sizeof(line), fd)) {
+ char *nl = strpbrk(line, "\r\n");
+ if (nl) *nl = '\0';
+ if (!*line || *line == '#') continue;
+
+ char *hash = strchr(line, ':');
+ if (!hash) continue;
+ *hash++ = '\0';
+ if (strcmp(line, user) != 0) continue;
+
+ char *role_s = strchr(hash, ':');
+ if (role_s) *role_s++ = '\0';
+
+ if (auth_verify_hash(hash, pass)) role = role_from_string(role_s);
+ break;
+ }
+
+ fclose(fd);
+ return role;
+}
diff --git a/src/util/auth.h b/src/util/auth.h
@@ -0,0 +1,35 @@
+#ifndef __LINKD_UTIL_AUTH_H__
+#define __LINKD_UTIL_AUTH_H__
+
+#include <stddef.h>
+
+// Password file, one entry per line:
+//
+// user:$pbkdf2-sha256$<iterations>$<salt>$<hash>:role
+//
+// role is `full` or `readonly`, and defaults to readonly when omitted.
+// salt and hash use passlib's adapted base64 ("." for "+", no padding), so
+// entries generated by passlib or Django interoperate.
+
+#define AUTH_ROLE_NONE 0
+#define AUTH_ROLE_READONLY 1
+#define AUTH_ROLE_FULL 2
+
+#define AUTH_DEFAULT_ITERATIONS 29000
+
+// Returns the role granted, or AUTH_ROLE_NONE on any failure.
+int auth_check(const char *path, const char *user, const char *pass);
+
+// Verify one `$pbkdf2-sha256$...` string against a password.
+int auth_verify_hash(const char *stored, const char *pass);
+
+// Build a hash string. Caller frees. NULL on failure.
+char *auth_make_hash(const char *pass, unsigned iterations);
+
+// Refuses a world-writable file; warns when group- or world-readable.
+int auth_check_permissions(const char *path);
+
+int ab64_encode(const unsigned char *in, size_t len, char *out, size_t out_sz);
+size_t ab64_decode(const char *in, unsigned char *out, size_t out_sz);
+
+#endif // __LINKD_UTIL_AUTH_H__
diff --git a/target/common/.dep b/target/common/.dep
@@ -1,3 +1,5 @@
cofyc/argparse https://git.finwo.net/misc/dep-repository/archives/heads/pkg/cofyc/argparse.tar.gz
finwo/cnfparse https://git.finwo.net/lib/cnfparse.c/archives/heads/main.tar.gz
+finwo/pbkdf2 https://git.finwo.net/lib/pbkdf2.c/archives/heads/main.tar.gz
+finwo/resp https://git.finwo.net/lib/resp.c/archives/heads/main.tar.gz
rxi/log https://git.finwo.net/misc/dep-repository/archives/heads/pkg/rxi/log.tar.gz
diff --git a/target/common/Makefile b/target/common/Makefile
@@ -10,8 +10,10 @@ SRC+=$(wildcard src/*/*/*.c)
CFLAGS?=-Wall -Wextra -O2
LDFLAGS?=
-# Dataplane plugins are dlopen'd at runtime
-LDFLAGS+=-ldl
+# Drop unreferenced code: dependencies export whole libraries, of which we use
+# a part (pbkdf2 also ships SHA-1).
+CFLAGS +=-ffunction-sections -fdata-sections
+LDFLAGS+=-Wl,--gc-sections
INCLUDES:=
INCLUDES+=-I src
diff --git a/tests/fixtures/plugin-echo.sh b/tests/fixtures/plugin-echo.sh
@@ -0,0 +1,77 @@
+#!/bin/sh
+# A linkd plugin in POSIX shell: RESP over stdin/stdout.
+#
+# Exists to prove a plugin needs no C, no library and no linking -- and to give
+# the test suite something to drive. It logs what it receives to
+# $PLUGIN_LOG so tests can assert on the commands linkd actually sent.
+#
+# Only the subset of RESP linkd sends is parsed: arrays of bulk strings.
+
+LOG="${PLUGIN_LOG:-/dev/null}"
+HARDWARE="${PLUGIN_HARDWARE:-1}"
+FAIL_VERB="${PLUGIN_FAIL_VERB:-}"
+
+# RESP replies. printf with \r\n; the protocol is CRLF-delimited.
+ok() { printf '+OK\r\n'; }
+err() { printf -- '-ERR %s\r\n' "$1"; }
+bulk() { printf '$%s\r\n%s\r\n' "${#1}" "$1"; }
+
+array() {
+ printf '*%s\r\n' "$#"
+ for a in "$@"; do printf '$%s\r\n%s\r\n' "${#a}" "$a"; done
+}
+
+# Read one RESP array into $ARGV (newline-separated). Returns 1 at EOF.
+read_command() {
+ ARGV=''
+ read -r line || return 1
+ line=${line%$(printf '\r')}
+ case "$line" in
+ '*'*) count=${line#\*} ;;
+ *) return 1 ;;
+ esac
+
+ i=0
+ while [ "$i" -lt "$count" ]; do
+ read -r hdr || return 1 # $<len>
+ read -r val || return 1 # payload
+ val=${val%$(printf '\r')}
+ ARGV="${ARGV}${val}
+"
+ i=$((i + 1))
+ done
+ return 0
+}
+
+while read_command; do
+ verb=$(printf '%s' "$ARGV" | sed -n '1p' | tr '[:lower:]' '[:upper:]')
+ sub=$(printf '%s' "$ARGV" | sed -n '2p' | tr '[:lower:]' '[:upper:]')
+ printf '%s' "$ARGV" | tr '\n' ' ' | sed 's/ *$//' >> "$LOG"
+ printf '\n' >> "$LOG"
+
+ if [ -n "$FAIL_VERB" ] && [ "$verb" = "$FAIL_VERB" ]; then
+ err "deliberate failure"
+ continue
+ fi
+
+ case "$verb" in
+ COMMAND) array PORT RIF NEIGH ROUTE RESYNC CONFIG INFO ;;
+ INFO) bulk "# Server
+name:echo
+version:0.1.0
+
+# Stats
+hardware_detected:${HARDWARE}
+" ;;
+ CONFIG)
+ case "$sub" in
+ LIST) array echo-setting echo-other ;;
+ SET) ok ;;
+ *) err "unknown CONFIG subcommand" ;;
+ esac
+ ;;
+ PORT|RIF|NEIGH|ROUTE|RESYNC) ok ;;
+ AUTH) ok ;;
+ *) err "unknown command $verb" ;;
+ esac
+done
diff --git a/tests/unit/test_auth.sh b/tests/unit/test_auth.sh
@@ -0,0 +1,228 @@
+#!/bin/sh
+# Password hashing: PBKDF2 correctness against OpenSSL, adapted-base64
+# round-trips, and the role model end to end.
+set -eu
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/../.." && pwd)
+. "${HERE}/../helpers.sh"
+
+echo "==> test_auth: password hashing and roles"
+
+BUILD_DIR=$(ensure_built "${ROOT}")
+BIN="${BUILD_DIR}/linkd"
+
+T=$(mktemp -d)
+trap 'rm -rf "${T}"' EXIT
+
+DEPINC="${BUILD_DIR}/lib/.dep/include"
+
+# Harness exposing the internals, so PBKDF2 output can be compared against a
+# reference implementation rather than only against itself.
+cat > "${T}/h.c" <<'EOF'
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include "finwo/pbkdf2.h"
+#include "util/auth.h"
+
+static void hex(const unsigned char *b, size_t n) {
+ for (size_t i = 0; i < n; i++) printf("%02x", b[i]);
+ printf("\n");
+}
+
+int main(int argc, char **argv) {
+ if (argc > 1 && !strcmp(argv[1], "kdf")) {
+ /* kdf <pass> <salt-ascii> <iters> -> hex of 32-byte key */
+ unsigned char out[32];
+ pbkdf2((const uint8_t*)argv[2], strlen(argv[2]),
+ (const uint8_t*)argv[3], strlen(argv[3]),
+ (unsigned long)atoi(argv[4]), PBKDF2_SHA256, out, sizeof(out));
+ hex(out, sizeof(out));
+ return 0;
+ }
+ if (argc > 1 && !strcmp(argv[1], "ab64")) {
+ /* ab64 <ascii> -> encoded, then decoded back as hex */
+ char enc[256];
+ unsigned char dec[256];
+ ab64_encode((const unsigned char*)argv[2], strlen(argv[2]), enc, sizeof(enc));
+ size_t n = ab64_decode(enc, dec, sizeof(dec));
+ printf("%s\n", enc);
+ hex(dec, n);
+ return 0;
+ }
+ if (argc > 1 && !strcmp(argv[1], "verify")) {
+ printf("%d\n", auth_verify_hash(argv[2], argv[3]));
+ return 0;
+ }
+ if (argc > 1 && !strcmp(argv[1], "make")) {
+ char *h = auth_make_hash(argv[2], (unsigned)atoi(argv[3]));
+ printf("%s\n", h ? h : "(null)");
+ return 0;
+ }
+ return 1;
+}
+EOF
+
+cc -O2 -I"${ROOT}/src" -I"${DEPINC}" -o "${T}/h" "${T}/h.c" \
+ "${ROOT}/src/util/auth.c" \
+ "${BUILD_DIR}/lib/finwo/pbkdf2/src/pbkdf2.o" \
+ "${BUILD_DIR}/lib/rxi/log/src/log.o" 2>"${T}/cc.log" || {
+ echo "SKIP: harness did not build"; sed -n '1,5p' "${T}/cc.log"; exit 0;
+}
+
+# --- PBKDF2 against OpenSSL ------------------------------------------------
+# Self-consistency proves nothing; a wrong HMAC is perfectly self-consistent.
+if command -v openssl >/dev/null 2>&1; then
+ for vec in "password:salt:1000" "secret:NaCl:4096" "x:0123456789abcdef:29000"; do
+ pass=$(printf '%s' "${vec}" | cut -d: -f1)
+ salt=$(printf '%s' "${vec}" | cut -d: -f2)
+ iter=$(printf '%s' "${vec}" | cut -d: -f3)
+
+ mine=$("${T}/h" kdf "${pass}" "${salt}" "${iter}")
+ ref=$(openssl kdf -keylen 32 -kdfopt digest:SHA256 \
+ -kdfopt "pass:${pass}" -kdfopt "salt:${salt}" -kdfopt "iter:${iter}" \
+ PBKDF2 2>/dev/null | tr -d ':' | tr 'A-F' 'a-f')
+ if [ -z "${ref}" ]; then
+ echo "SKIP: openssl kdf unavailable for cross-check"
+ break
+ fi
+ assert_eq "${mine}" "${ref}" "PBKDF2-SHA256 matches OpenSSL (${pass}/${salt}/${iter})"
+ done
+else
+ echo "SKIP: openssl not available"
+fi
+
+# --- adapted base64 --------------------------------------------------------
+out=$("${T}/h" ab64 "hello world")
+enc=$(printf '%s' "${out}" | sed -n 1p)
+dec=$(printf '%s' "${out}" | sed -n 2p)
+want=$(printf 'hello world' | od -An -tx1 | tr -d ' \n')
+assert_eq "${dec}" "${want}" "adapted base64 round-trips"
+
+case "${enc}" in
+ *=*) echo "FAIL: adapted base64 must not emit padding" >&2; exit 1 ;;
+ *+*) echo "FAIL: adapted base64 must use '.' not '+'" >&2; exit 1 ;;
+ *) echo "PASS: adapted base64 has no padding and no '+'" ;;
+esac
+
+# --- hash round-trip -------------------------------------------------------
+h=$("${T}/h" make "correct horse" 1000)
+case "${h}" in
+ '$pbkdf2-sha256$1000$'*) echo "PASS: hash string carries scheme and iterations" ;;
+ *) echo "FAIL: unexpected hash format: ${h}" >&2; exit 1 ;;
+esac
+
+assert_eq "$("${T}/h" verify "${h}" "correct horse")" "1" "correct password verifies"
+assert_eq "$("${T}/h" verify "${h}" "wrong")" "0" "wrong password rejected"
+assert_eq "$("${T}/h" verify "${h}" "")" "0" "empty password rejected"
+
+# Two hashes of the same password must differ: the salt has to be random.
+h2=$("${T}/h" make "correct horse" 1000)
+if [ "${h}" = "${h2}" ]; then
+ echo "FAIL: identical hashes for the same password; salt is not random" >&2
+ exit 1
+fi
+echo "PASS: salt is random across invocations"
+
+# Tampering with the stored iteration count must not validate.
+assert_eq "$("${T}/h" verify "\$pbkdf2-sha256\$1\$abc\$def" "correct horse")" "0" \
+ "malformed hash rejected"
+
+# --- roles over a live daemon ----------------------------------------------
+command -v unshare >/dev/null 2>&1 || { echo "SKIP: unshare unavailable"; exit 0; }
+unshare -Urn true 2>/dev/null || { echo "SKIP: user namespaces unavailable"; exit 0; }
+
+mkdir -p "${T}/bin"
+for l in linkd linkctl ifquery; do ln -sf "${BIN}" "${T}/bin/${l}"; done
+
+RO=$("${T}/h" make "ropass" 1000)
+FU=$("${T}/h" make "fupass" 1000)
+printf 'watcher:%s:readonly\nadmin:%s:full\nnorole:%s\n' "${RO}" "${FU}" "${RO}" > "${T}/passwd"
+chmod 600 "${T}/passwd"
+
+cat > "${T}/interfaces" <<EOF
+auto lo
+iface lo
+ address 127.0.0.1/8
+EOF
+
+cat > "${T}/linkd.cnf" <<EOF
+config_iface ${T}/interfaces
+authfile ${T}/passwd
+listen unix://${T}/linkd.sock
+listen tcp://127.0.0.1:16797
+EOF
+
+cat > "${T}/raw.c" <<'EOF'
+#include <stdio.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <netinet/in.h>
+#include <arpa/inet.h>
+#include <sys/socket.h>
+#include <unistd.h>
+int main(int argc, char **argv) {
+ (void)argc;
+ char req[8192];
+ size_t len = fread(req, 1, sizeof(req), stdin);
+ int fd = socket(AF_INET, SOCK_STREAM, 0);
+ struct sockaddr_in a; memset(&a, 0, sizeof(a));
+ a.sin_family = AF_INET; a.sin_port = htons((uint16_t)atoi(argv[1]));
+ a.sin_addr.s_addr = inet_addr("127.0.0.1");
+ if (connect(fd, (struct sockaddr*)&a, sizeof(a))) return 1;
+ write(fd, req, len);
+ shutdown(fd, SHUT_WR);
+ char b[8192]; ssize_t n, t = 0;
+ while ((n = read(fd, b+t, sizeof(b)-t-1)) > 0) t += n;
+ b[t] = 0; fwrite(b, 1, t, stdout);
+ return 0;
+}
+EOF
+cc -O2 -o "${T}/raw" "${T}/raw.c" 2>/dev/null || { echo "SKIP: no raw client"; exit 0; }
+
+OUT="${T}/out"
+timeout 60 unshare -Urn sh -c "
+ '${T}/bin/linkd' --config '${T}/linkd.cnf' --ready-file '${T}/ready' --resync-interval 0 >'${T}/daemon.log' 2>&1 &
+ for i in \$(seq 1 80); do [ -e '${T}/ready' ] && break; sleep 0.25; done
+
+ echo '--badpass--'
+ printf '*3\r\n\$4\r\nAUTH\r\n\$5\r\nadmin\r\n\$5\r\nWRONG\r\n' | '${T}/raw' 16797
+
+ echo '--readonly-query--'
+ printf '*3\r\n\$4\r\nAUTH\r\n\$7\r\nwatcher\r\n\$6\r\nropass\r\n*2\r\n\$7\r\nIFQUERY\r\n\$2\r\nlo\r\n' | '${T}/raw' 16797 | head -3
+
+ echo '--readonly-mutate--'
+ printf '*3\r\n\$4\r\nAUTH\r\n\$7\r\nwatcher\r\n\$6\r\nropass\r\n*2\r\n\$4\r\nIFUP\r\n\$2\r\nlo\r\n' | '${T}/raw' 16797 | tail -1
+
+ echo '--full-mutate--'
+ printf '*3\r\n\$4\r\nAUTH\r\n\$5\r\nadmin\r\n\$6\r\nfupass\r\n*2\r\n\$4\r\nIFUP\r\n\$2\r\nlo\r\n' | '${T}/raw' 16797 | tail -1
+
+ echo '--norole-defaults-readonly--'
+ printf '*3\r\n\$4\r\nAUTH\r\n\$6\r\nnorole\r\n\$6\r\nropass\r\n*2\r\n\$4\r\nIFUP\r\n\$2\r\nlo\r\n' | '${T}/raw' 16797 | tail -1
+
+ echo '--end--'
+ kill %1 2>/dev/null
+" > "${OUT}" 2>&1 || true
+
+section() { sed -n "/^--$1--\$/,/^--/p" "${OUT}" | sed '1d;$d'; }
+
+assert_contains "$(section badpass)" "WRONGPASS" "wrong password rejected by daemon"
+assert_contains "$(section readonly-query)" "+OK" "readonly user authenticates"
+assert_contains "$(section readonly-mutate)" "NOPERM" "readonly user cannot mutate"
+assert_contains "$(section full-mutate)" "+OK" "full user can mutate"
+assert_contains "$(section norole-defaults-readonly)" "NOPERM" \
+ "entry without a role field defaults to readonly"
+
+# A world-writable password file lets anyone grant themselves access.
+chmod 666 "${T}/passwd"
+out=$(timeout 30 unshare -Urn sh -c "
+ '${T}/bin/linkd' --config '${T}/linkd.cnf' --ready-file '${T}/ready2' --resync-interval 0 >'${T}/d2.log' 2>&1 &
+ for i in \$(seq 1 60); do [ -e '${T}/ready2' ] && break; sleep 0.25; done
+ printf '*3\r\n\$4\r\nAUTH\r\n\$5\r\nadmin\r\n\$6\r\nfupass\r\n' | '${T}/raw' 16797
+ kill %1 2>/dev/null
+" 2>&1 || true)
+assert_contains "${out}" "WRONGPASS" "world-writable password file is not honoured"
+assert_file_contains "${T}/d2.log" "world-writable" "world-writable file is reported"
+
+echo "==> test_auth done"
diff --git a/tests/unit/test_netlink.sh b/tests/unit/test_netlink.sh
@@ -50,29 +50,45 @@ echo "PASS: no /sbin/ip reference in linkd sources"
# containing netlink sends, the two checks above are meaningless.
assert_path_exists "${SRC}/netlink/rtnl.c" "rtnl.c present (the netlink path exists)"
-# --- 2. the accepted IPC connection must be blocking ----------------------
-# A non-blocking accepted fd made the server read EAGAIN, stdio report EOF, and
-# cfg_parse see an empty stream -> empty reply. That was the root cause of the
-# IPC flakiness, not timing.
+# --- 2. accepted connections must be non-blocking -------------------------
+# This is the inverse of what it once asserted. Under the line protocol the
+# server read a connection to EOF with stdio, so a non-blocking fd returned
+# EAGAIN, stdio reported EOF, and the reply came back empty.
+#
+# RESP is length-prefixed, so a partial read is detectable rather than
+# indistinguishable from end-of-input: resp_read_buf reports "incomplete" and
+# the connection waits for more data. Blocking accepts would now let one
+# client stall netlink for the whole daemon.
IPC="${SRC}/ipc.c"
assert_path_exists "${IPC}" "ipc.c present"
-if grep -q 'accept4(ipc_sock.*SOCK_NONBLOCK' "${IPC}"; then
- echo "FAIL: accepted IPC conn is non-blocking (reintroduces empty-reply race)" >&2
+if ! grep -q 'accept4(.*SOCK_NONBLOCK' "${IPC}"; then
+ echo "FAIL: accepted connections are blocking; one slow client stalls netlink" >&2
exit 1
fi
-echo "PASS: accepted IPC connection is blocking"
+echo "PASS: accepted connections are non-blocking"
-# --- 3. clients must half-close after writing -----------------------------
-# Without shutdown(SHUT_WR) the server blocks waiting for a second command
-# until its receive timeout expires.
-for c in ifup ifdown ifquery ifreload linkctl; do
- f="${SRC}/cli/${c}.c"
- assert_path_exists "${f}" "cli/${c}.c present"
- if ! grep -q 'shutdown(sock, SHUT_WR)' "${f}"; then
- echo "FAIL: ${c} does not shutdown(SHUT_WR) after write" >&2
- exit 1
- fi
-done
-echo "PASS: all clients half-close after sending the command"
+# Non-blocking is only correct with incremental parsing behind it.
+if ! grep -q 'resp_read_buf' "${IPC}"; then
+ echo "FAIL: no resp_read_buf; non-blocking reads need incremental parsing" >&2
+ exit 1
+fi
+echo "PASS: reads are buffered through resp_read_buf"
+
+# Partial writes must be buffered too, or a slow reader stalls the daemon
+# just as effectively as a slow writer would.
+if ! grep -q 'POLLOUT' "${IPC}"; then
+ echo "FAIL: no POLLOUT handling; a slow reader would block the daemon" >&2
+ exit 1
+fi
+echo "PASS: writes are buffered and drained on POLLOUT"
+
+# --- 3. one client must not be able to wedge the daemon -------------------
+# The old server serviced a single connection per poll wakeup, synchronously.
+if grep -q 'IPC_MAX_CONNS' "${SRC}/ipc.h"; then
+ echo "PASS: server keeps a bounded connection table"
+else
+ echo "FAIL: no connection limit; an unbounded fd table is a DoS" >&2
+ exit 1
+fi
echo "==> test_netlink done"
diff --git a/tests/unit/test_plugin.sh b/tests/unit/test_plugin.sh
@@ -0,0 +1,113 @@
+#!/bin/sh
+# Plugin subsystem: handshake, config delivery, broadcast, failure semantics.
+#
+# Driven by tests/fixtures/plugin-echo.sh, a plugin written in POSIX shell.
+# That is deliberate: if the suite passes with a shell plugin, the claim that
+# plugins need no particular language is tested rather than asserted.
+set -eu
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/../.." && pwd)
+. "${HERE}/../helpers.sh"
+
+echo "==> test_plugin: RESP plugin subsystem"
+
+BUILD_DIR=$(ensure_built "${ROOT}")
+BIN="${BUILD_DIR}/linkd"
+FIXTURE="${ROOT}/tests/fixtures/plugin-echo.sh"
+assert_path_exists "${FIXTURE}" "shell plugin fixture present"
+
+command -v unshare >/dev/null 2>&1 || { echo "SKIP: unshare unavailable"; exit 0; }
+unshare -Urn true 2>/dev/null || { echo "SKIP: user namespaces unavailable"; exit 0; }
+
+T=$(mktemp -d)
+trap 'rm -rf "${T}"' EXIT
+mkdir -p "${T}/net"
+
+cat > "${T}/net/interfaces" <<EOF
+auto lo
+iface lo
+ address 127.0.0.1/8
+EOF
+
+cat > "${T}/net/ports" <<EOF
+port swp1
+ speed 25000
+ fec rs
+ autoneg 0
+EOF
+
+# Runs linkd to completion against a config, returning its log.
+run_linkd() {
+ # run_linkd <config> [env assignments...]
+ _cfg=$1; shift
+ env "$@" PLUGIN_LOG="${T}/plugin.log" \
+ timeout 15 unshare -Urn "${BIN}" --config "${_cfg}" --resync-interval 0 \
+ >"${T}/linkd.log" 2>&1 || true
+ cat "${T}/linkd.log"
+}
+
+# --- handshake + config delivery -------------------------------------------
+cat > "${T}/full.cnf" <<EOF
+config_iface ${T}/net/interfaces
+config_ports ${T}/net/ports
+listen unix://${T}/linkd.sock
+plugin ${FIXTURE}
+ echo-setting value1 value2
+EOF
+
+: > "${T}/plugin.log"
+run_linkd "${T}/full.cnf" >/dev/null
+
+assert_file_contains "${T}/plugin.log" "COMMAND" "plugin asked for COMMAND"
+assert_file_contains "${T}/plugin.log" "INFO" "plugin asked for INFO"
+assert_file_contains "${T}/plugin.log" "CONFIG LIST" "plugin asked for CONFIG LIST"
+assert_file_contains "${T}/plugin.log" "CONFIG SET echo-setting value1 value2" \
+ "unknown sub-directive delivered as CONFIG SET"
+
+# Operations reach the plugin in the documented subcommand form.
+assert_file_contains "${T}/plugin.log" "PORT APPLY swp1 speed 25000 fec rs autoneg 0" \
+ "ports config broadcast as PORT APPLY"
+assert_file_contains "${T}/plugin.log" "PORT ADMIN lo up" "netlink link event broadcast"
+assert_file_contains "${T}/plugin.log" "RIF ADD lo 127.0.0.1/8" "address event broadcast as RIF ADD"
+
+# --- hardware_detected gates the plugin ------------------------------------
+: > "${T}/plugin.log"
+out=$(run_linkd "${T}/full.cnf" PLUGIN_HARDWARE=0)
+assert_contains "${out}" "no supported hardware" "plugin reporting no hardware is refused"
+
+# --- failure semantics ------------------------------------------------------
+# A required plugin failing an operation fails that operation.
+: > "${T}/plugin.log"
+out=$(run_linkd "${T}/full.cnf" PLUGIN_FAIL_VERB=PORT)
+assert_contains "${out}" "port_admin lo failed" "required plugin failure fails the operation"
+
+# The same plugin marked optional does not.
+cat > "${T}/opt.cnf" <<EOF
+config_iface ${T}/net/interfaces
+listen unix://${T}/linkd.sock
+plugin ${FIXTURE}
+ optional
+EOF
+: > "${T}/plugin.log"
+out=$(run_linkd "${T}/opt.cnf" PLUGIN_FAIL_VERB=PORT)
+case "${out}" in
+ *"port_admin lo failed"*)
+ echo "FAIL: optional plugin failure still failed the operation" >&2; exit 1 ;;
+ *"deliberate failure"*)
+ echo "PASS: optional plugin failure logged but not fatal" ;;
+ *)
+ echo "FAIL: optional plugin was not consulted at all" >&2; exit 1 ;;
+esac
+
+# --- no plugins configured --------------------------------------------------
+cat > "${T}/none.cnf" <<EOF
+config_iface ${T}/net/interfaces
+listen unix://${T}/linkd.sock
+EOF
+out=$(run_linkd "${T}/none.cnf")
+case "${out}" in
+ *"ERROR"*plugin*) echo "FAIL: plugin errors without any plugin configured" >&2; exit 1 ;;
+ *) echo "PASS: no plugins configured is not an error" ;;
+esac
+
+echo "==> test_plugin done"
diff --git a/tests/unit/test_resp.sh b/tests/unit/test_resp.sh
@@ -0,0 +1,125 @@
+#!/bin/sh
+# RESP server: protocol, pipelining, concurrency and the authorization model.
+set -eu
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/../.." && pwd)
+. "${HERE}/../helpers.sh"
+
+echo "==> test_resp: RESP server"
+
+BUILD_DIR=$(ensure_built "${ROOT}")
+BIN="${BUILD_DIR}/linkd"
+
+command -v unshare >/dev/null 2>&1 || { echo "SKIP: unshare unavailable"; exit 0; }
+unshare -Urn true 2>/dev/null || { echo "SKIP: user namespaces unavailable"; exit 0; }
+
+T=$(mktemp -d)
+trap 'rm -rf "${T}"' EXIT
+mkdir -p "${T}/bin"
+
+for l in linkd linkctl ifup ifdown ifquery ifreload; do ln -sf "${BIN}" "${T}/bin/${l}"; done
+
+cat > "${T}/interfaces" <<EOF
+auto lo
+iface lo
+ address 127.0.0.1/8
+ address ::1/128
+EOF
+
+cat > "${T}/linkd.cnf" <<EOF
+config_iface ${T}/interfaces
+authfile ${T}/linkd.passwd
+listen unix://${T}/linkd.sock
+listen tcp://127.0.0.1:16793
+EOF
+: > "${T}/linkd.passwd"
+
+# Request comes from stdin, not argv: shell quoting mangles CRLF escapes.
+cat > "${T}/raw.c" <<'EOF'
+#include <stdio.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/un.h>
+#include <unistd.h>
+int main(int argc, char **argv) {
+ (void)argc;
+ char req[65536];
+ size_t len = fread(req, 1, sizeof(req), stdin);
+ int fd = socket(AF_UNIX, SOCK_STREAM, 0);
+ struct sockaddr_un a; memset(&a, 0, sizeof(a)); a.sun_family = AF_UNIX;
+ strncpy(a.sun_path, argv[1], sizeof(a.sun_path)-1);
+ if (connect(fd, (struct sockaddr*)&a, sizeof(a))) return 1;
+ write(fd, req, len);
+ shutdown(fd, SHUT_WR);
+ char b[65536]; ssize_t n, t = 0;
+ while ((n = read(fd, b+t, sizeof(b)-t-1)) > 0) t += n;
+ b[t] = 0; fwrite(b, 1, t, stdout);
+ return 0;
+}
+EOF
+cc -O2 -o "${T}/raw" "${T}/raw.c" 2>/dev/null || { echo "SKIP: no compiler for raw client"; exit 0; }
+
+OUT="${T}/out"
+timeout 60 unshare -Urn sh -c "
+ '${T}/bin/linkd' --config '${T}/linkd.cnf' --ready-file '${T}/ready' --resync-interval 0 >'${T}/daemon.log' 2>&1 &
+ for i in \$(seq 1 80); do [ -e '${T}/ready' ] && break; sleep 0.25; done
+ export LINKD_CONFIG='${T}/linkd.cnf'
+
+ echo '--ping--'; '${T}/bin/linkctl' PING
+ echo '--echo--'; '${T}/bin/linkctl' PING hello
+ echo '--query--'; '${T}/bin/ifquery' lo
+ echo '--unknown--'; '${T}/bin/linkctl' FROBNICATE 2>&1 || true
+ echo '--missing--'; '${T}/bin/ifquery' nosuch 2>&1 || true
+ echo '--arity--'; '${T}/bin/linkctl' IFUP 2>&1 || true
+ echo '--mutate--'; '${T}/bin/ifup' lo
+
+ echo '--pipeline--'
+ printf '*1\r\n\$4\r\nPING\r\n*2\r\n\$4\r\nPING\r\n\$3\r\ntwo\r\n*1\r\n\$4\r\nPING\r\n' \
+ | '${T}/raw' '${T}/linkd.sock'
+
+ echo '--concurrent--'
+ pids=''
+ for i in \$(seq 1 8); do '${T}/bin/linkctl' PING & pids=\"\$pids \$!\"; done
+ for p in \$pids; do wait \$p; done
+
+ echo '--tcp-ping--'
+ printf '*1\r\n\$4\r\nPING\r\n' | timeout 5 nc 127.0.0.1 16793 2>/dev/null | head -1
+ echo '--tcp-noauth--'
+ printf '*2\r\n\$7\r\nIFQUERY\r\n\$2\r\nlo\r\n' | timeout 5 nc 127.0.0.1 16793 2>/dev/null | head -1
+ echo '--end--'
+
+ kill %1 2>/dev/null
+" > "${OUT}" 2>&1 || true
+
+section() { sed -n "/^--$1--\$/,/^--/p" "${OUT}" | sed '1d;$d'; }
+
+assert_contains "$(section ping)" "PONG" "PING replies PONG"
+assert_contains "$(section echo)" "hello" "PING <msg> echoes it"
+assert_contains "$(section query)" "iface lo" "IFQUERY renders as config syntax"
+assert_contains "$(section query)" "address 127.0.0.1/8" "IFQUERY returns addresses"
+assert_contains "$(section query)" "address ::1/128" "IFQUERY returns IPv6 addresses"
+assert_contains "$(section unknown)" "unknown command" "unknown command rejected"
+assert_contains "$(section missing)" "no such interface" "missing interface rejected"
+assert_contains "$(section arity)" "wrong number of arguments" "arity is enforced"
+assert_contains "$(section mutate)" "OK" "root over unix may mutate"
+
+pipeline=$(section pipeline)
+n=$(printf '%s' "${pipeline}" | grep -c 'PONG' || true)
+assert_eq "${n}" "2" "pipelined PINGs answered (2 bare PONG + 1 echo)"
+assert_contains "${pipeline}" "two" "pipelined echo answered in order"
+
+n=$(section concurrent | grep -c 'PONG' || true)
+assert_eq "${n}" "8" "8 concurrent clients all served"
+
+assert_contains "$(section tcp-ping)" "PONG" "PING is allowed before AUTH"
+assert_contains "$(section tcp-noauth)" "NOAUTH" "other commands require AUTH over tcp"
+
+cat > "${T}/noauth.cnf" <<EOF
+config_iface ${T}/interfaces
+listen unix://${T}/linkd2.sock
+listen tcp://127.0.0.1:16794
+EOF
+out=$(timeout 15 unshare -Urn "${BIN}" --config "${T}/noauth.cnf" 2>&1 || true)
+assert_contains "${out}" "refusing to listen" "tcp without authfile is refused"
+
+echo "==> test_resp done"