commit dacf038ab263016b646983a09539da397b2779ec
parent d3b47d654f4e399419f20aaaac7b0939f36997df
Author: finwo <finwo@pm.me>
Date: Fri, 18 Sep 2026 19:42:40 +0200
Partial vrf implementation
Diffstat:
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);