linkd

Control plane daemon for unos
git clone git://git.finwo.net/app/linkd
Log | Files | Refs | README

commit dacf038ab263016b646983a09539da397b2779ec
parent d3b47d654f4e399419f20aaaac7b0939f36997df
Author: finwo <finwo@pm.me>
Date:   Fri, 18 Sep 2026 19:42:40 +0200

Partial vrf implementation

Diffstat:
Msrc/cli/unosd.c | 31++++++++++++++++++++++++++-----
Msrc/config/ifaces.c | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/config/ifaces.h | 15+++++++++++++++
Msrc/ipc.c | 27+++++++++++++++++++++------
Msrc/netlink/rtnl.c | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/netlink/rtnl.h | 6++++++
Msrc/util/config.c | 51++++++++++++++++++++++++++++++++++++++++++++++++---
7 files changed, 270 insertions(+), 20 deletions(-)

diff --git a/src/cli/unosd.c b/src/cli/unosd.c @@ -156,8 +156,27 @@ static int apply_ports(void) { return 0; } +// Applies the already-parsed in-memory config (ifaces_list()); nothing here +// reads the filesystem. Two ordered phases, because interfaces depend on each +// other: a member cannot join a VRF that does not exist yet, and members are +// routinely declared first -- the production Cumulus config we captured has +// `iface eth0` carrying `vrf mgmt` above the `iface mgmt` stanza that defines +// it. Bridges need no such phase: membership is declared on the bridge +// (`bridge-ports`), not on the member. static int apply_interfaces(void) { struct unos_iface *cur; + + // Phase 1: bring every VRF device into existence. + for (cur = ifaces_list(); cur; cur = cur->next) { + if (!cur->auto_flag) continue; + if (!iface_is_vrf(cur)) continue; + log_info("apply_interfaces: vrf %s table %d", cur->name, cur->vrf_table); + rtnl_vrf_create(cur->name, (uint32_t)cur->vrf_table); + rtnl_link_up(cur->name); + } + + // Phase 2: apply every interface, VRF devices included (create is + // idempotent -- rtnl_vrf_create returns early when the device exists). for (cur = ifaces_list(); cur; cur = cur->next) { if (!cur->auto_flag) continue; if (cur->pre_up) { @@ -186,11 +205,7 @@ static int apply_interfaces(void) { rtnl_vlan_create(cur->name, raw, cur->vlan_id); } } - bool is_bridge = false; - if (cur->bridge_ports) is_bridge = true; - else if (cur->bridge_stp || cur->bridge_vlan_aware >= 0) is_bridge = true; - else if (!strncmp(cur->name, "br", 2) && cur->vlan_id < 0) is_bridge = true; - if (is_bridge) { + if (iface_is_bridge(cur)) { log_info("apply_interfaces: bridge %s ports %s", cur->name, cur->bridge_ports ? cur->bridge_ports : "(none)"); rtnl_bridge_create(cur->name); if (cur->bridge_stp) { @@ -210,6 +225,12 @@ static int apply_interfaces(void) { free(ports); } } + // Enslave to a VRF before bringing the link up and assigning addresses: + // moving an interface into a VRF flushes its addresses, so doing it after + // would silently discard everything we just configured. + if (cur->vrf_master) { + rtnl_vrf_add_port(cur->vrf_master, cur->name); + } log_info("apply_interfaces: link up %s", cur->name); rtnl_link_up(cur->name); for (struct iface_addr *a = cur->addrs; a; a = a->next) { diff --git a/src/config/ifaces.c b/src/config/ifaces.c @@ -12,6 +12,74 @@ static struct unos_iface *ifaces = NULL; static struct unos_iface *ifaces_tail = NULL; +// Allocate an iface with every "unset" field at its sentinel. +// +// calloc() zeroes, which is the correct "unset" for pointers and for counters +// like mtu, but WRONG for any field whose valid range includes 0. Those use -1 +// as the sentinel and must be set explicitly. +// +// This exists because bridge_vlan_aware was documented as "-1 unset" but never +// initialised, so it sat at 0 and the `bridge_vlan_aware >= 0` test classified +// EVERY interface as a bridge. It was masked only because creating a bridge on +// an existing device fails harmlessly. Centralising the sentinels here means a +// new tri-state field cannot repeat that by being forgotten at one of the two +// allocation sites. +static struct unos_iface * iface_new(const char *name) { + struct unos_iface *ifc = calloc(1, sizeof(*ifc)); + if (!ifc) return NULL; + ifc->name = name ? strdup(name) : NULL; + ifc->vlan_id = -1; + ifc->bridge_vlan_aware = -1; + ifc->vrf_table = -1; + return ifc; +} + +// Is this interface a bridge? +// +// Single source of truth: this was duplicated in cli/unosd.c and ipc.c, which +// is how the two could drift. Explicit bridge-* directives win; the name +// prefix is only a fallback, and never applies to something already declared +// a VLAN or enslaved to a VRF. +bool iface_is_bridge(const struct unos_iface *ifc) { + if (!ifc) return false; + if (ifc->bridge_ports) return true; + if (ifc->bridge_stp) return true; + if (ifc->bridge_vlan_aware >= 0) return true; + if (ifc->vlan_id >= 0) return false; + if (ifc->vrf_table >= 0) return false; + return !strncmp(ifc->name, "br", 2); +} + +// Is this interface a VRF? `vrf-table` makes it one; `vrf <name>` makes it a +// MEMBER of one, which is a different thing entirely. +bool iface_is_vrf(const struct unos_iface *ifc) { + return ifc && ifc->vrf_table >= 0; +} + +// `vrf-table auto`: lowest free id at or above VRF_TABLE_AUTO_BASE. +// +// 1001 is not arbitrary -- it is where Cumulus starts, and the S5248F-ON we +// captured has its `mgmt` VRF on exactly table 1001 (PROGRESS.md 2.1). Matching +// that means a config moved off a Cumulus box lands on the same tables. +// Deliberately skips 253/254/255 by construction, and skips any id already +// claimed explicitly, so `auto` and a hardcoded id cannot collide. +#define VRF_TABLE_AUTO_BASE 1001 +static int vrf_table_auto(const char *name) { + struct unos_iface *cur; + int candidate; + for (candidate = VRF_TABLE_AUTO_BASE; candidate < 2147483647; candidate++) { + int taken = 0; + for (cur = ifaces; cur; cur = cur->next) { + // An interface re-parsing its own stanza must keep its table. + if (name && cur->name && !strcmp(cur->name, name)) continue; + if (cur->vrf_table == candidate) { taken = 1; break; } + } + if (!taken) return candidate; + } + fprintf(stderr, "vrf-table auto: no free table id\n"); + exit(1); +} + struct unos_iface * ifaces_list(void) { return ifaces; } @@ -31,9 +99,7 @@ void ifaces_set_auto(const char *name, int flag) { return; } // Create placeholder for auto without iface block yet (common in Debian: auto comes before iface) - cur = calloc(1, sizeof(*cur)); - cur->name = strdup(name); - cur->vlan_id = -1; + cur = iface_new(name); cur->auto_flag = flag; if (ifaces_tail) { ifaces_tail->next = cur; @@ -62,6 +128,7 @@ void ifaces_free(void) { free(cur->post_down); free(cur->bridge_ports); free(cur->bridge_stp); + free(cur->vrf_master); for (a = cur->addrs; a; a = anext) { anext = a->next; free(a->address); @@ -156,11 +223,9 @@ struct cnf_directive * cfg_parse_iface(FILE *fd, struct cnf_directive *dir, void // keep auto_flag } else { // create new - iface = calloc(1, sizeof(*iface)); - iface->name = strdup(name); + iface = iface_new(name); iface->method = method ? strdup(method) : NULL; iface->family = family ? strdup(family) : NULL; - iface->vlan_id = -1; // check if auto was previously set for this name via placeholder? Already handled, but if not placeholder, check if any placeholder had auto // Actually ifaces_set_auto may have created placeholder with auto, we already handled. // If no placeholder, auto_flag stays 0 until auto directive is parsed (order may be auto before or after iface) @@ -340,6 +405,33 @@ struct cnf_directive * cfg_parse_iface(FILE *fd, struct cnf_directive *dir, void else if (!strcasecmp(dir->argv[0], "no") || !strcasecmp(dir->argv[0], "off") || !strcasecmp(dir->argv[0], "0")) iface->bridge_vlan_aware = 0; else { fprintf(stderr, "`bridge-vlan-aware` expects yes/no\n"); exit(1); } } + // `vrf <name>`: enslave this interface to an existing VRF device. + else if (!strcasecmp("vrf", dir->name)) { + if (dir->argc != 1) { fprintf(stderr, "`vrf` expects 1 arg (vrf device name)\n"); exit(1); } + free(iface->vrf_master); + iface->vrf_master = strdup(dir->argv[0]); + } + // `vrf-table <id|auto>`: THIS interface is a VRF device with that table. + // Cumulus spells the automatic case `auto`; we allocate from a private + // range rather than colliding with main(254)/local(255)/default(253). + else if (!strcasecmp("vrf-table", dir->name) || !strcasecmp("vrf_table", dir->name)) { + if (dir->argc != 1) { fprintf(stderr, "`vrf-table` expects 1 arg (table id or `auto`)\n"); exit(1); } + if (!strcasecmp(dir->argv[0], "auto")) { + iface->vrf_table = vrf_table_auto(iface->name); + } else { + char *end = NULL; + long v = strtol(dir->argv[0], &end, 10); + if (!end || *end || v <= 0 || v > 2147483647L) { + fprintf(stderr, "`vrf-table` expects a positive table id or `auto`, got `%s`\n", dir->argv[0]); + exit(1); + } + if (v == 253 || v == 254 || v == 255) { + fprintf(stderr, "`vrf-table` %ld is a reserved table (default/main/local)\n", v); + exit(1); + } + iface->vrf_table = (int)v; + } + } else { break; } diff --git a/src/config/ifaces.h b/src/config/ifaces.h @@ -1,6 +1,7 @@ #ifndef __UNOS_CONFIG_IFACES_H__ #define __UNOS_CONFIG_IFACES_H__ +#include <stdbool.h> #include <stdint.h> #include <stdio.h> @@ -33,10 +34,24 @@ struct unos_iface { char *bridge_ports; // space-separated, e.g. "swp1 swp2" char *bridge_stp; // "on"/"off" int bridge_vlan_aware; // -1 unset, 0 no, 1 yes + // VRF. Two distinct roles, deliberately separate fields: + // vrf_table >= 0 -> THIS interface is a VRF device with that table id + // (`vrf-table <id|auto>`) + // vrf_master -> this interface is a MEMBER of the named VRF + // (`vrf <name>`) + // Both appear in every production Cumulus config we captured; a mgmt VRF is + // how management traffic is kept out of the table BGP populates. + int vrf_table; // -1 unset, otherwise 1..2^31-1 + char *vrf_master; // NULL unset int auto_flag; // 1 if listed in auto struct unos_iface *next; }; +// Shared classification. Previously duplicated in cli/unosd.c and ipc.c, which +// let the two drift; keep exactly one definition. +bool iface_is_bridge(const struct unos_iface *ifc); +bool iface_is_vrf(const struct unos_iface *ifc); + void ifaces_register(void); struct cnf_directive * cfg_parse_auto(FILE *fd, struct cnf_directive *dir, void *user); diff --git a/src/ipc.c b/src/ipc.c @@ -312,12 +312,15 @@ static int handle_ifup(FILE *out, const char *ifname, uid_t uid) { rtnl_vlan_create(ifname, raw, iface->vlan_id); } } - // Bridge creation -- explicit bridge-ports (including "none") or br* without vlan - bool is_bridge = false; - if (iface->bridge_ports) is_bridge = true; - else if (iface->bridge_stp || iface->bridge_vlan_aware >= 0) is_bridge = true; - else if (!strncmp(iface->name, "br", 2) && iface->vlan_id < 0) is_bridge = true; - if (is_bridge) { + // VRF device: create before anything else touches it. `ifup <vrf>` on its + // own 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)"); rtnl_bridge_create(ifname); if (iface->bridge_stp) { @@ -341,6 +344,12 @@ static int handle_ifup(FILE *out, const char *ifname, uid_t uid) { } } // 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. + 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); return -1; @@ -486,6 +495,12 @@ static int handle_ifquery(FILE *out, const char *ifname) { 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); diff --git a/src/netlink/rtnl.c b/src/netlink/rtnl.c @@ -406,6 +406,62 @@ int rtnl_bridge_create(const char *name) { return rtnl_talk(nh); } +// Create a VRF device bound to a routing table. +// +// A VRF is an L3 master device: enslaved interfaces have their routes looked +// up in IFLA_VRF_TABLE instead of main. The table id is not decoration -- it +// IS the separation, which is why it is mandatory here rather than defaulted. +// Requires CONFIG_NET_VRF (shipped as a module, see the kernel templates). +int rtnl_vrf_create(const char *name, uint32_t table) { + if (!name || !table) return -1; + if (if_nametoindex(name) != 0) { + log_info("rtnl: vrf %s already exists", name); + return 0; + } + char buf[NL_BUFSZ]; + struct nlmsghdr *nh = (struct nlmsghdr*)buf; + struct ifinfomsg *ifi; + memset(buf, 0, sizeof(buf)); + nh->nlmsg_len = NLMSG_LENGTH(sizeof(*ifi)); + nh->nlmsg_type = RTM_NEWLINK; + nh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL; + ifi = NLMSG_DATA(nh); + ifi->ifi_family = AF_UNSPEC; + addattr_l(nh, sizeof(buf), IFLA_IFNAME, name, strlen(name)+1); + int nest = addattr_nested_start(nh, sizeof(buf), IFLA_LINKINFO); + addattr_l(nh, sizeof(buf), IFLA_INFO_KIND, "vrf", 4); + int nest2 = addattr_nested_start(nh, sizeof(buf), IFLA_INFO_DATA); + addattr_l(nh, sizeof(buf), IFLA_VRF_TABLE, &table, sizeof(table)); + addattr_nested_end(nh, nest2); + addattr_nested_end(nh, nest); + log_info("rtnl: vrf %s table %u", name, table); + return rtnl_talk(nh); +} + +// Enslave an interface to a VRF. Same IFLA_MASTER mechanism as bridge +// enslavement -- the kernel distinguishes them by the master's kind. +int rtnl_vrf_add_port(const char *vrf, const char *port) { + unsigned vidx = if_nametoindex(vrf); + unsigned pidx = if_nametoindex(port); + if (!vidx || !pidx) { + log_error("rtnl: vrf_add_port %s -> %s: ifindex not found (%u, %u)", port, vrf, pidx, vidx); + return -1; + } + char buf[NL_BUFSZ]; + struct nlmsghdr *nh = (struct nlmsghdr*)buf; + struct ifinfomsg *ifi; + memset(buf, 0, sizeof(buf)); + nh->nlmsg_len = NLMSG_LENGTH(sizeof(*ifi)); + nh->nlmsg_type = RTM_NEWLINK; + nh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; + ifi = NLMSG_DATA(nh); + ifi->ifi_family = AF_UNSPEC; + ifi->ifi_index = pidx; + addattr_l(nh, sizeof(buf), IFLA_MASTER, &vidx, sizeof(vidx)); + log_info("rtnl: vrf %s enslave %s", vrf, port); + return rtnl_talk(nh); +} + int rtnl_bridge_set_stp(const char *name, bool on) { unsigned ifindex = if_nametoindex(name); if (!ifindex) return -1; diff --git a/src/netlink/rtnl.h b/src/netlink/rtnl.h @@ -22,6 +22,12 @@ int rtnl_route_del_default(const char *gateway, const char *ifname); int rtnl_vlan_create(const char *name, const char *rawdev, int vlan_id); // Bridge: create br with stp/vlan_aware, and enslave ports (space-separated) +// VRF: create an L3 master device bound to a routing table, and enslave +// interfaces to it. `table` is mandatory -- it is what actually separates the +// VRF's routes from main. +int rtnl_vrf_create(const char *name, uint32_t table); +int rtnl_vrf_add_port(const char *vrf, const char *port); + int rtnl_bridge_create(const char *name); int rtnl_bridge_set_stp(const char *name, bool on); int rtnl_bridge_set_vlan_aware(const char *name, bool on); diff --git a/src/util/config.c b/src/util/config.c @@ -51,7 +51,39 @@ void cfg_register_directive(struct cfg_ns *ns, const char *name, cfg_directive_f ns->count++; } -// source-directory handler: transforms `source-directory <dir>` into `source <dir>/*` +// Internal directive name used to mark a glob that came from +// `source-directory` and must therefore obey run-parts filename rules. Not +// reachable from a config file: `@` is not a legal directive name character. +#define CFG_SOURCE_DIR_MARKER "@source-directory" + +// run-parts naming, as interfaces(5) specifies for source-directory: a file is +// sourced only if its name consists entirely of ASCII letters, digits, +// underscores and hyphens. +// +// This is not pedantry. The rule exists to skip editor backups, dpkg-dist +// files and anything else with a dot or tilde in it. The production Cumulus +// switch we captured has seven such files sitting in /etc/network/interfaces.d +// (030-swp.intf.10469.2026-09-08@09:32:31~ and friends); without this filter +// we would source stale duplicate config and die on a duplicate `iface`. +// +// Note this necessarily excludes `foo.cnf` too -- which is why our own shipped +// configs use an explicit `source <dir>/*.cnf` glob rather than +// source-directory. Explicit globs are not filtered. +static int name_is_runparts(const char *name) { + const char *p; + if (!name || !*name) return 0; + for (p = name; *p; p++) { + if (*p >= 'a' && *p <= 'z') continue; + if (*p >= 'A' && *p <= 'Z') continue; + if (*p >= '0' && *p <= '9') continue; + if (*p == '_' || *p == '-') continue; + return 0; + } + return 1; +} + +// source-directory handler: transforms `source-directory <dir>` into a +// run-parts-filtered glob of <dir>/* static struct cnf_directive *cfg_handle_source_directory(FILE *fd, struct cnf_directive *dir, void *user) { (void)fd; (void)user; struct cnf_directive *ndir = NULL; @@ -70,7 +102,7 @@ static struct cnf_directive *cfg_handle_source_directory(FILE *fd, struct cnf_di // Synthesize a `source <dir>/*` directive // Allocate new directive manually ndir = calloc(1, sizeof(*ndir)); - ndir->name = strdup("source"); + ndir->name = strdup(CFG_SOURCE_DIR_MARKER); ndir->argc = 1; ndir->argv = calloc(1, sizeof(char*)); // Append /* if not already ending with /* @@ -110,7 +142,10 @@ int cfg_parse(struct cfg_ns *ns, const char *wd, FILE *fd, void *user) { cfg_parse_reparse: // Core handlers: source and source-directory (source-directory is via handler table, but we also handle source here as built-in) - if (!strcasecmp("source", dir->name)) { + if (!strcasecmp("source", dir->name) || !strcasecmp(CFG_SOURCE_DIR_MARKER, dir->name)) { + // Only source-directory filters filenames; an explicit `source foo/*.cnf` + // means exactly what it says. + int runparts = !strcasecmp(CFG_SOURCE_DIR_MARKER, dir->name); globflags = GLOB_ERR; for ( i = 0 ; i < (int)dir->argc ; i++ ) { if (dir->argv[i][0] == '/') { @@ -131,6 +166,16 @@ int cfg_parse(struct cfg_ns *ns, const char *wd, FILE *fd, void *user) { globflags = GLOB_ERR | GLOB_APPEND; } for ( i = 0 ; i < (int)globbuf.gl_pathc ; i++ ) { + if (runparts) { + char *bcopy = strdup(globbuf.gl_pathv[i]); + const char *base = basename(bcopy); + int ok = name_is_runparts(base); + if (!ok) { + fprintf(stderr, "config: source-directory: skipping `%s` (not run-parts safe)\n", base); + } + free(bcopy); + if (!ok) continue; + } strtmp = calloc(strlen(globbuf.gl_pathv[i])+1, 1); strcpy(strtmp, globbuf.gl_pathv[i]); char *dname = strdup(strtmp);