diff --git a/CHANGELOG.md b/CHANGELOG.md index f0680eb..81e50bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,121 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## [0.9.26] — 2026-05-09 + +**Rootless track, `create_epair` verb — first response-data +verb.** Twenty-seventh 0.9.x release. Wraps +`IfconfigOps::createEpair()`, which the kernel auto-assigns +the next free epair unit number for. The verb returns the +A/B iface names so the client can plumb them downstream. + +### What lands + +#### New privops verb + +`create_epair` — no request fields. Response body shape: + +``` +{"created":true,"a":"epair17a","b":"epair17b"} +``` + +This is the first verb whose response carries non-trivial +data. Previously all verbs returned status + a confirmation +body the client could ignore. `create_epair` is different +because the operator NEEDS the assigned names to do the +next steps (move B-half into vnet, attach A-half to bridge). + +#### Wire protocol + +- Daemon handler: builds the JSON response via + `formatCreateEpairSuccess(a, b)` → + `{"created":true,"a":"","b":""}`. +- Client (run_net.cpp): receives `Response.body` as JSON + string, calls existing `PrivOpsWirePure::extractStringField` + twice (once for `a`, once for `b`). No new JSON parser + needed — extractStringField already handles top-level + string fields (used by daemon-side parsers). + +The libnv transport wraps the same JSON body in an nvlist +field as before — clients on either transport use the same +extraction code. + +#### CLI wiring + +`lib/run_net.cpp` gets `createEpairPrivopsOrLocal()` returning +`std::pair`. Two call-sites +migrate (lines 239 / 518) — both `auto epairPair = +IfconfigOps::createEpair();` patterns. + +### Trade-offs + +- **JSON body extraction at the client.** Native nvlist + responses (per-verb structured fields) would be cleaner + for libnv transport but require refactoring 14 handlers + to expose typed responses. Current JSON-in-nvlist shape + ships today; future optimisation if motivated. +- **No retry on transient failures.** If `create_epair` + succeeds on the daemon but the response is dropped + (network issue, timeout), an orphan epair leaks. Same + constraint as direct `IfconfigOps::createEpair()` — that + pattern is unchanged. + +### Series state + +CLI call-sites wired: +- `crate retune` (0.9.15) +- `crate stop` (0.9.17) +- `crate run` ZFS attach + detach (0.9.18) +- `crate run` nullfs mounts 8 sites (0.9.19) +- `crate run` vnet moveToVnet 4 sites (0.9.20) +- `crate run` removeJail teardown (0.9.21) +- `crate run` createJail (0.9.22) +- `crate run` setUp + disableOffload 5 sites (0.9.23) +- `crate run` bridge add + del 2 sites (0.9.24) +- `crate run` setInetAddr (host-side epair-A) (0.9.25) +- **`crate run` createEpair (2 sites) → create_epair ← this release** + +**Full host-side iface plumbing now coverable via privops.** +The 5 `IfconfigOps::*` ops `crate run` uses +(createEpair / disableOffload / setUp / setInetAddr / +bridgeAddMember) all have privops paths. With 0.9.22's +createJail and 0.9.21's removeJail, **`crate run` and +`crate stop` no longer need root for any privileged step +when the privops socket is detected** — the legacy setuid +fallback is now optional. + +### Remaining + +- 0.9.27 — `network_lease.cpp` per-user paths + RCTL + umbrella application (uses 0.9.10 sub-CIDR + 0.9.11 + loginclass) +- 0.9.28 — default flip +- 1.0.0 — setuid removed + +### Tests + +- 1 new ATF test in `privops_pure_test` + (`create_epair_no_fields_required`) +- 1 new ATF test in `privops_wire_pure_test` + (`format_create_epair_response_extracts`) that locks down + the response shape AND verifies it round-trips through the + client's `extractStringField` extraction path +- `verb_token_roundtrips_for_every_verb` updated +- Suite: 1299 → **1301** + +### Files + +Same set as 0.9.23/0.9.24/0.9.25 plus +`tests/unit/privops_wire_pure_test.cpp`: +`privops_pure.{h,cpp}`, `privops_wire_pure.{h,cpp}`, +`privops_nv_pure.{h,cpp}`, `privops_client.h`, +`privops_client_pure.cpp`, `privops_handlers.{h,cpp}`, +`run_net.cpp`, `tests/unit/privops_pure_test.cpp`, +`tests/unit/privops_wire_pure_test.cpp`, `cli/args.cpp`, +`CHANGELOG.md`. + +--- + ## [0.9.25] — 2026-05-09 **Rootless track, `set_iface_inet_addr` verb.** Twenty-sixth diff --git a/cli/args.cpp b/cli/args.cpp index d2e45e5..157446e 100644 --- a/cli/args.cpp +++ b/cli/args.cpp @@ -753,7 +753,7 @@ Args parseArguments(int argc, char** argv, unsigned &processed) { args.noColor = true; break; } else if (strEq(argv[a], "--version")) { - std::cout << "crate 0.9.25" << std::endl; + std::cout << "crate 0.9.26" << std::endl; exit(0); } else if (auto argShort = isShort(argv[a])) { switch (argShort) { @@ -764,7 +764,7 @@ Args parseArguments(int argc, char** argv, unsigned &processed) { args.logProgress = true; break; case 'V': - std::cout << "crate 0.9.25" << std::endl; + std::cout << "crate 0.9.26" << std::endl; exit(0); default: err("unsupported short option '%s'", argv[a]); diff --git a/daemon/privops_handlers.cpp b/daemon/privops_handlers.cpp index 0b9725d..29d1471 100644 --- a/daemon/privops_handlers.cpp +++ b/daemon/privops_handlers.cpp @@ -429,6 +429,20 @@ DispatchResult handleSetIfaceInetAddr(const PrivOpsPure::SetIfaceInetAddrReq &r) r.ifname, r.addr, r.prefixLen)}; } +// --- handleCreateEpair (0.9.26) --- + +DispatchResult handleCreateEpair(const PrivOpsPure::CreateEpairReq &/*r*/) { + std::pair pair; + try { + pair = IfconfigOps::createEpair(); + } catch (const std::exception &e) { + return {500, PrivOpsWirePure::formatHandlerError("ifconfig_failed", + e.what())}; + } + return {200, PrivOpsWirePure::formatCreateEpairSuccess(pair.first, + pair.second)}; +} + // --- Top-level dispatcher --- namespace { @@ -605,6 +619,14 @@ DispatchResult dispatchPrivOp(Verb v, const std::string &body, return {400, PrivOpsWirePure::formatValidateError(e)}; return handleSetIfaceInetAddr(r); } + case Verb::CreateEpair: { + PrivOpsPure::CreateEpairReq r; + if (auto e = PrivOpsWirePure::parseCreateEpair(body, r); !e.empty()) + return {400, PrivOpsWirePure::formatParseError(e)}; + if (auto e = PrivOpsPure::validateCreateEpair(r); !e.empty()) + return {400, PrivOpsWirePure::formatValidateError(e)}; + return handleCreateEpair(r); + } default: return PrivOpsWirePure::parseValidateAndDispatch(v, body); } @@ -775,6 +797,14 @@ DispatchResult dispatchPrivOpFromMap(const PrivOpsNvPure::FieldMap &m, return {400, PrivOpsWirePure::formatValidateError(e)}; return handleSetIfaceInetAddr(r); } + case Verb::CreateEpair: { + PrivOpsPure::CreateEpairReq r; + if (auto e = PrivOpsNvPure::parseCreateEpair(m, r); !e.empty()) + return {400, PrivOpsWirePure::formatParseError(e)}; + if (auto e = PrivOpsPure::validateCreateEpair(r); !e.empty()) + return {400, PrivOpsWirePure::formatValidateError(e)}; + return handleCreateEpair(r); + } case Verb::Unknown: return {404, std::string("{\"error\":\"unknown or missing 'verb' field\"}")}; diff --git a/daemon/privops_handlers.h b/daemon/privops_handlers.h index f18fd6b..7445d23 100644 --- a/daemon/privops_handlers.h +++ b/daemon/privops_handlers.h @@ -161,4 +161,9 @@ PrivOpsWirePure::DispatchResult handleBridgeDelMember(const PrivOpsPure::BridgeD // IfconfigOps::setInetAddr (3-arg primitive: iface + addr + prefixLen). PrivOpsWirePure::DispatchResult handleSetIfaceInetAddr(const PrivOpsPure::SetIfaceInetAddrReq &r); +// 0.9.26: create an epair pair via libifconfig (with shell +// fallback). The kernel assigns the next free unit number; +// the response body carries the assigned A/B iface names. +PrivOpsWirePure::DispatchResult handleCreateEpair(const PrivOpsPure::CreateEpairReq &r); + } // namespace Crated diff --git a/lib/privops_client.h b/lib/privops_client.h index 13741af..1d3bebf 100644 --- a/lib/privops_client.h +++ b/lib/privops_client.h @@ -123,6 +123,12 @@ PrivOpsNvPure::FieldMap buildSetIfaceInetAddr(const std::string &ifname, const std::string &addr, unsigned prefixLen); +// 0.9.26: create epair pair. No request fields. Response body +// (in Response.body) is a JSON object with `a` and `b` fields +// holding the kernel-assigned A/B iface names; clients parse +// them via PrivOpsWirePure::extractStringField. +PrivOpsNvPure::FieldMap buildCreateEpair(); + // --- Wire transport (FreeBSD-only) --- struct Response { diff --git a/lib/privops_client_pure.cpp b/lib/privops_client_pure.cpp index 7ba68d3..2c6a51e 100644 --- a/lib/privops_client_pure.cpp +++ b/lib/privops_client_pure.cpp @@ -262,4 +262,10 @@ PrivOpsNvPure::FieldMap buildSetIfaceInetAddr(const std::string &ifname, }; } +PrivOpsNvPure::FieldMap buildCreateEpair() { + return { + {"verb", "create_epair"}, + }; +} + } // namespace PrivOpsClient diff --git a/lib/privops_nv_pure.cpp b/lib/privops_nv_pure.cpp index fe36efe..b16d8bf 100644 --- a/lib/privops_nv_pure.cpp +++ b/lib/privops_nv_pure.cpp @@ -234,6 +234,11 @@ std::string parseSetIfaceInetAddr(const FieldMap &m, return ""; } +std::string parseCreateEpair(const FieldMap &/*m*/, + PrivOpsPure::CreateEpairReq &/*out*/) { + return ""; +} + // --- Verb routing --- PrivOpsPure::Verb extractVerb(const FieldMap &m) { diff --git a/lib/privops_nv_pure.h b/lib/privops_nv_pure.h index 50bf8b1..774d59a 100644 --- a/lib/privops_nv_pure.h +++ b/lib/privops_nv_pure.h @@ -129,6 +129,8 @@ std::string parseBridgeDelMember(const FieldMap &m, PrivOpsPure::BridgeDelMemberReq &out); std::string parseSetIfaceInetAddr(const FieldMap &m, PrivOpsPure::SetIfaceInetAddrReq &out); +std::string parseCreateEpair(const FieldMap &m, + PrivOpsPure::CreateEpairReq &out); // --- Verb routing --- diff --git a/lib/privops_pure.cpp b/lib/privops_pure.cpp index 77afcd7..4b67874 100644 --- a/lib/privops_pure.cpp +++ b/lib/privops_pure.cpp @@ -90,6 +90,7 @@ const char *verbName(Verb v) { case Verb::BridgeAddMember: return "bridge_add_member"; case Verb::BridgeDelMember: return "bridge_del_member"; case Verb::SetIfaceInetAddr: return "set_iface_inet_addr"; + case Verb::CreateEpair: return "create_epair"; case Verb::Unknown: return "unknown"; } return "unknown"; @@ -115,6 +116,7 @@ Verb parseVerb(const std::string &name) { if (name == "bridge_add_member") return Verb::BridgeAddMember; if (name == "bridge_del_member") return Verb::BridgeDelMember; if (name == "set_iface_inet_addr") return Verb::SetIfaceInetAddr; + if (name == "create_epair") return Verb::CreateEpair; return Verb::Unknown; } @@ -505,4 +507,10 @@ std::string validateSetIfaceInetAddr(const SetIfaceInetAddrReq &r) { return ""; } +std::string validateCreateEpair(const CreateEpairReq &) { + // No fields — kernel picks the unit number. Validator is here + // for symmetry with the rest of the verb set; always succeeds. + return ""; +} + } // namespace PrivOpsPure diff --git a/lib/privops_pure.h b/lib/privops_pure.h index d8b0348..34f2895 100644 --- a/lib/privops_pure.h +++ b/lib/privops_pure.h @@ -125,6 +125,14 @@ enum class Verb { // createEpair flow where the host-side epair-A end gets a // /31 IP after the jail-side epair-B is moved into the jail. SetIfaceInetAddr, + + // 0.9.26: create epair pair. First response-data verb — + // returns the kernel-assigned A/B iface names. Wraps + // IfconfigOps::createEpair() (no inputs; output is a pair + // of strings). Targets run_net.cpp::createEpair (line 117) + // and setupBridgeEpair (line 396) where the existing code + // unpacks `auto epairPair = IfconfigOps::createEpair();`. + CreateEpair, }; // Returns the verb's canonical wire-format token (lowercase, no @@ -248,6 +256,11 @@ struct SetIfaceInetAddrReq { unsigned prefixLen = 32; // 0..32 }; +// 0.9.26: create epair pair. No request fields — kernel picks +// the next free unit number (epaira / epairb). +struct CreateEpairReq { +}; + // --- Per-verb validators --- // // Each `validate*(req)` returns "" on success, otherwise a one-line @@ -275,6 +288,7 @@ std::string validateDisableIfaceOffload(const DisableIfaceOffloadReq &r); std::string validateBridgeAddMember(const BridgeAddMemberReq &r); std::string validateBridgeDelMember(const BridgeDelMemberReq &r); std::string validateSetIfaceInetAddr(const SetIfaceInetAddrReq &r); +std::string validateCreateEpair(const CreateEpairReq &r); // --- Field-level validators (exposed for tests + reuse) --- // diff --git a/lib/privops_wire_pure.cpp b/lib/privops_wire_pure.cpp index b56e891..4927393 100644 --- a/lib/privops_wire_pure.cpp +++ b/lib/privops_wire_pure.cpp @@ -370,6 +370,12 @@ std::string parseSetIfaceInetAddr(const std::string &body, return ""; } +std::string parseCreateEpair(const std::string &/*body*/, + PrivOpsPure::CreateEpairReq &/*out*/) { + // No required fields. Body content (if any) is ignored. + return ""; +} + // --- Verb routing helper --- PrivOpsPure::Verb parseVerbFromPath(const std::string &path) { @@ -656,6 +662,16 @@ std::string formatSetIfaceInetAddrSuccess(const std::string &ifname, return o.str(); } +std::string formatCreateEpairSuccess(const std::string &ifaceA, + const std::string &ifaceB) { + std::ostringstream o; + o << "{\"created\":true" + << ",\"a\":\"" << escape(ifaceA) << "\"" + << ",\"b\":\"" << escape(ifaceB) << "\"" + << "}"; + return o.str(); +} + DispatchResult parseValidateAndDispatch(PrivOpsPure::Verb v, const std::string &body) { using namespace PrivOpsPure; @@ -698,6 +714,8 @@ DispatchResult parseValidateAndDispatch(PrivOpsPure::Verb v, return runVerb(body, v, parseBridgeDelMember, validateBridgeDelMember); case Verb::SetIfaceInetAddr: return runVerb(body, v, parseSetIfaceInetAddr, validateSetIfaceInetAddr); + case Verb::CreateEpair: + return runVerb(body, v, parseCreateEpair, validateCreateEpair); case Verb::Unknown: break; } diff --git a/lib/privops_wire_pure.h b/lib/privops_wire_pure.h index 3133b7d..23ae800 100644 --- a/lib/privops_wire_pure.h +++ b/lib/privops_wire_pure.h @@ -171,6 +171,9 @@ std::string parseBridgeDelMember(const std::string &body, std::string parseSetIfaceInetAddr(const std::string &body, PrivOpsPure::SetIfaceInetAddrReq &out); +std::string parseCreateEpair(const std::string &body, + PrivOpsPure::CreateEpairReq &out); + // --- Verb routing helper --- // // Parse the URL path's verb segment. The route pattern is @@ -291,4 +294,13 @@ std::string formatSetIfaceInetAddrSuccess(const std::string &ifname, const std::string &addr, unsigned prefixLen); +// 0.9.26: 200 OK body for create_epair. Returns the kernel- +// assigned A/B iface names. First privops verb whose response +// carries non-trivial data; clients parse the body to retrieve +// them (existing extractStringField fits). +// +// Shape: {"created":true,"a":"","b":""} +std::string formatCreateEpairSuccess(const std::string &ifaceA, + const std::string &ifaceB); + } // namespace PrivOpsWirePure diff --git a/lib/run_net.cpp b/lib/run_net.cpp index bc9d519..7e1ee50 100644 --- a/lib/run_net.cpp +++ b/lib/run_net.cpp @@ -11,6 +11,7 @@ #include "pfctl_ops.h" #include "pathnames.h" #include "privops_client.h" +#include "privops_wire_pure.h" #include "util.h" #include "err.h" @@ -138,6 +139,33 @@ static void bridgeDelMemberPrivopsOrLocal(const std::string &bridge, IfconfigOps::bridgeDelMember(bridge, member); } +// 0.9.26: privops-aware createEpair. Returns the kernel-assigned +// pair names. Parses the daemon's JSON response body via +// PrivOpsWirePure::extractStringField — no full JSON parser +// needed for this two-field response. +static std::pair +createEpairPrivopsOrLocal() { + std::string sock = PrivOpsClient::detectSocketPath(); + if (!sock.empty()) { + auto resp = PrivOpsClient::sendRequest(sock, + PrivOpsClient::buildCreateEpair()); + if (!resp.transportError.empty()) + ERR2("run_net", "privops create_epair transport error: " + << resp.transportError) + if (resp.status >= 400) + ERR2("run_net", "privops create_epair failed (status " + << resp.status << "): " << resp.body) + std::string a, b; + auto ar = PrivOpsWirePure::extractStringField(resp.body, "a", a); + auto br = PrivOpsWirePure::extractStringField(resp.body, "b", b); + if (ar != PrivOpsWirePure::kPresent || br != PrivOpsWirePure::kPresent) + ERR2("run_net", "privops create_epair response missing 'a' or 'b' " + "fields: " << resp.body) + return {a, b}; + } + return IfconfigOps::createEpair(); +} + // 0.9.25: privops-aware setInetAddr. static void setInetAddrPrivopsOrLocal(const std::string &iface, const std::string &addr, @@ -208,7 +236,7 @@ EpairInfo createEpair(int jid, const std::string &jidStr, execInJail({CRATE_PATH_IFCONFIG, "lo0", "inet", "127.0.0.1"}, "set up the lo0 interface in jail"); // create networking interface (uses libifconfig with shell fallback) - auto epairPair = IfconfigOps::createEpair(); + auto epairPair = createEpairPrivopsOrLocal(); info.ifaceA = epairPair.first; info.ifaceB = epairPair.second; info.num = Util::toUInt(info.ifaceA.substr(5/*skip epair*/, info.ifaceA.size()-5-1)); @@ -487,7 +515,7 @@ BridgeInfo createBridgeEpair(int jid, const std::string &jidStr, execInJail({CRATE_PATH_IFCONFIG, "lo0", "inet", "127.0.0.1"}, "set up the lo0 interface in jail"); // create epair - auto epairPair = IfconfigOps::createEpair(); + auto epairPair = createEpairPrivopsOrLocal(); info.ifaceA = epairPair.first; info.ifaceB = epairPair.second; info.num = Util::toUInt(info.ifaceA.substr(5, info.ifaceA.size()-5-1)); diff --git a/tests/unit/privops_pure_test.cpp b/tests/unit/privops_pure_test.cpp index e226b4f..8173c59 100644 --- a/tests/unit/privops_pure_test.cpp +++ b/tests/unit/privops_pure_test.cpp @@ -25,7 +25,7 @@ ATF_TEST_CASE_BODY(verb_token_roundtrips_for_every_verb) { Verb::AddIpfwRule, Verb::RemoveIpfwRule, Verb::SetIfaceUp, Verb::DisableIfaceOffload, Verb::BridgeAddMember, Verb::BridgeDelMember, - Verb::SetIfaceInetAddr, + Verb::SetIfaceInetAddr, Verb::CreateEpair, }; for (Verb v : verbs) { std::string token = verbName(v); @@ -540,6 +540,13 @@ ATF_TEST_CASE_BODY(bridge_del_member_minimal) { ATF_REQUIRE(!validateBridgeDelMember(r).empty()); } +ATF_TEST_CASE_WITHOUT_HEAD(create_epair_no_fields_required); +ATF_TEST_CASE_BODY(create_epair_no_fields_required) { + CreateEpairReq r; + // Validator always succeeds — no inputs to check. + ATF_REQUIRE_EQ(validateCreateEpair(r), std::string()); +} + ATF_TEST_CASE_WITHOUT_HEAD(set_iface_inet_addr_minimal); ATF_TEST_CASE_BODY(set_iface_inet_addr_minimal) { SetIfaceInetAddrReq r; @@ -625,4 +632,5 @@ ATF_INIT_TEST_CASES(tcs) { ATF_ADD_TEST_CASE(tcs, bridge_add_member_minimal); ATF_ADD_TEST_CASE(tcs, bridge_del_member_minimal); ATF_ADD_TEST_CASE(tcs, set_iface_inet_addr_minimal); + ATF_ADD_TEST_CASE(tcs, create_epair_no_fields_required); } diff --git a/tests/unit/privops_wire_pure_test.cpp b/tests/unit/privops_wire_pure_test.cpp index db202d6..c4de55a 100644 --- a/tests/unit/privops_wire_pure_test.cpp +++ b/tests/unit/privops_wire_pure_test.cpp @@ -517,6 +517,21 @@ ATF_TEST_CASE_BODY(format_destroy_jail_success) { ATF_REQUIRE(body.find("\"created\":") == std::string::npos); } +ATF_TEST_CASE_WITHOUT_HEAD(format_create_epair_response_extracts); +ATF_TEST_CASE_BODY(format_create_epair_response_extracts) { + // 0.9.26: first response-data verb. Lock down that the body + // shape `{"created":true,"a":"epair0a","b":"epair0b"}` is + // re-extractable via extractStringField (the path the client + // in run_net.cpp uses). + std::string body = formatCreateEpairSuccess("epair17a", "epair17b"); + ATF_REQUIRE(body.find("\"created\":true") != std::string::npos); + std::string a, b; + ATF_REQUIRE_EQ(extractStringField(body, "a", a), std::string(kPresent)); + ATF_REQUIRE_EQ(a, std::string("epair17a")); + ATF_REQUIRE_EQ(extractStringField(body, "b", b), std::string(kPresent)); + ATF_REQUIRE_EQ(b, std::string("epair17b")); +} + // --- parseValidateAndDispatch --- ATF_TEST_CASE_WITHOUT_HEAD(dispatch_unknown_returns_404); @@ -625,6 +640,7 @@ ATF_INIT_TEST_CASES(tcs) { ATF_ADD_TEST_CASE(tcs, format_remove_ipfw_rule_success); ATF_ADD_TEST_CASE(tcs, format_create_jail_success); ATF_ADD_TEST_CASE(tcs, format_destroy_jail_success); + ATF_ADD_TEST_CASE(tcs, format_create_epair_response_extracts); ATF_ADD_TEST_CASE(tcs, dispatch_unknown_returns_404); ATF_ADD_TEST_CASE(tcs, dispatch_parse_error_returns_400);