Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,100 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## [0.9.28] — 2026-05-09

**Rootless track, RCTL umbrella verbs.** Twenty-ninth 0.9.x
release. Two new verbs that apply RCTL rules at the
loginclass scope (umbrella) instead of per-jail. The
infrastructure for 0.9.11's `crate-<uid>` loginclass is now
addressable end-to-end.

### What lands

#### Two new privops verbs

- **`set_loginclass_rctl`** — wraps
`rctl -a loginclass:<name>:<key>:deny=<value>`. Fields:
`loginclass`, `key`, `value`. Validates
`loginclass` via `PerUserRctlPure::validateLoginclassName`
(must be `crate-<uid>` shape), and `key`/`value` via
the existing `RetunePure` whitelist (same gate as
`set_rctl` from 0.9.0).
- **`clear_loginclass_rctl`** — symmetric remove via
`rctl -r loginclass:<name>:<key>:deny`. Fields:
`loginclass`, `key`.

#### Use case

Today's `set_rctl` (0.9.0) is jail-scoped — alice can
exceed her aggregate quota by spawning multiple jails,
each below the per-jail cap. With `set_loginclass_rctl`,
the kernel enforces a sum across all of alice's jails:

```
# Pre-0.9.28: alice spawns 3 jails, each at 2G memoryuse.
# Total = 6G. Per-jail set_rctl can't catch this.

# 0.9.28: at provisioning time, set the umbrella once:
POST /api/v1/privops/set_loginclass_rctl
{"loginclass":"crate-1000","key":"memoryuse","value":"4G"}

# Now: alice spawns 3 jails. Kernel enforces 4G total
# regardless of how many jails she has.
```

The umbrella rule and per-jail rules apply simultaneously
— kernel takes the more restrictive of the two whenever
both fire.

### Wire-up

Same files as previous verb-expansion releases —
`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}`. Two
new functions/cases per file.

`privops_pure.cpp` gains `#include "per_user_rctl_pure.h"`
to reuse `validateLoginclassName`.

### CLI wiring (intentionally none for 0.9.28)

The verbs are primitives — no automatic invocation from
`crate run` or other CLI commands. Operators wanting
umbrella rules call them directly (e.g., from a startup
script) or wait for **0.9.29** which will auto-apply
umbrella rules from `crated.conf` at jail-create time.

This split keeps 0.9.28 small and reviewable — pure verb
addition. Auto-application requires a config-schema decision
(per-key map vs structured spec) that's worth its own PR.

### Series state

CLI call-sites wired (12+ in total). All host-side verbs
needed for `crate run` exist. RCTL umbrella primitives now
exist; auto-application coming in 0.9.29.

### Tests

- 2 new ATF tests in `privops_pure_test`
(`set_loginclass_rctl_validates`,
`clear_loginclass_rctl_validates`) covering happy path
+ bad loginclass + bad key + out-of-range value.
- `verb_token_roundtrips_for_every_verb` updated.
- Suite: 1301 → **1303**.

### Files

`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}`,
`tests/unit/privops_pure_test.cpp`, `cli/args.cpp`,
`CHANGELOG.md`.

---

## [0.9.27] — 2026-05-09

**Rootless track, per-user lease file path.** Twenty-eighth
Expand Down
4 changes: 2 additions & 2 deletions cli/args.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.27" << std::endl;
std::cout << "crate 0.9.28" << std::endl;
exit(0);
} else if (auto argShort = isShort(argv[a])) {
switch (argShort) {
Expand All @@ -764,7 +764,7 @@ Args parseArguments(int argc, char** argv, unsigned &processed) {
args.logProgress = true;
break;
case 'V':
std::cout << "crate 0.9.27" << std::endl;
std::cout << "crate 0.9.28" << std::endl;
exit(0);
default:
err("unsupported short option '%s'", argv[a]);
Expand Down
62 changes: 62 additions & 0 deletions daemon/privops_handlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,36 @@ DispatchResult handleCreateEpair(const PrivOpsPure::CreateEpairReq &/*r*/) {
pair.second)};
}

// --- handleSetLoginclassRctl / handleClearLoginclassRctl (0.9.28) ---

DispatchResult handleSetLoginclassRctl(const PrivOpsPure::SetLoginclassRctlReq &r) {
// Build `rctl -a loginclass:<name>:<key>:deny=<value>` argv.
// Mirrors handleSetRctl (0.9.2) but with loginclass subject.
std::string rule = "loginclass:" + r.loginclass + ":" + r.key
+ ":deny=" + r.rawValue;
try {
Util::execCommand({CRATE_PATH_RCTL, "-a", rule},
"privops set_loginclass_rctl");
} catch (const std::exception &e) {
return {500, PrivOpsWirePure::formatHandlerError("exec_failed", e.what())};
}
return {200, PrivOpsWirePure::formatSetLoginclassRctlSuccess(
r.loginclass, r.key, r.rawValue)};
}

DispatchResult handleClearLoginclassRctl(const PrivOpsPure::ClearLoginclassRctlReq &r) {
// `rctl -r loginclass:<name>:<key>:deny` — symmetric remove.
std::string subject = "loginclass:" + r.loginclass + ":" + r.key + ":deny";
try {
Util::execCommand({CRATE_PATH_RCTL, "-r", subject},
"privops clear_loginclass_rctl");
} catch (const std::exception &e) {
return {500, PrivOpsWirePure::formatHandlerError("exec_failed", e.what())};
}
return {200, PrivOpsWirePure::formatClearLoginclassRctlSuccess(
r.loginclass, r.key)};
}

// --- Top-level dispatcher ---

namespace {
Expand Down Expand Up @@ -627,6 +657,22 @@ DispatchResult dispatchPrivOp(Verb v, const std::string &body,
return {400, PrivOpsWirePure::formatValidateError(e)};
return handleCreateEpair(r);
}
case Verb::SetLoginclassRctl: {
PrivOpsPure::SetLoginclassRctlReq r;
if (auto e = PrivOpsWirePure::parseSetLoginclassRctl(body, r); !e.empty())
return {400, PrivOpsWirePure::formatParseError(e)};
if (auto e = PrivOpsPure::validateSetLoginclassRctl(r); !e.empty())
return {400, PrivOpsWirePure::formatValidateError(e)};
return handleSetLoginclassRctl(r);
}
case Verb::ClearLoginclassRctl: {
PrivOpsPure::ClearLoginclassRctlReq r;
if (auto e = PrivOpsWirePure::parseClearLoginclassRctl(body, r); !e.empty())
return {400, PrivOpsWirePure::formatParseError(e)};
if (auto e = PrivOpsPure::validateClearLoginclassRctl(r); !e.empty())
return {400, PrivOpsWirePure::formatValidateError(e)};
return handleClearLoginclassRctl(r);
}
default:
return PrivOpsWirePure::parseValidateAndDispatch(v, body);
}
Expand Down Expand Up @@ -805,6 +851,22 @@ DispatchResult dispatchPrivOpFromMap(const PrivOpsNvPure::FieldMap &m,
return {400, PrivOpsWirePure::formatValidateError(e)};
return handleCreateEpair(r);
}
case Verb::SetLoginclassRctl: {
PrivOpsPure::SetLoginclassRctlReq r;
if (auto e = PrivOpsNvPure::parseSetLoginclassRctl(m, r); !e.empty())
return {400, PrivOpsWirePure::formatParseError(e)};
if (auto e = PrivOpsPure::validateSetLoginclassRctl(r); !e.empty())
return {400, PrivOpsWirePure::formatValidateError(e)};
return handleSetLoginclassRctl(r);
}
case Verb::ClearLoginclassRctl: {
PrivOpsPure::ClearLoginclassRctlReq r;
if (auto e = PrivOpsNvPure::parseClearLoginclassRctl(m, r); !e.empty())
return {400, PrivOpsWirePure::formatParseError(e)};
if (auto e = PrivOpsPure::validateClearLoginclassRctl(r); !e.empty())
return {400, PrivOpsWirePure::formatValidateError(e)};
return handleClearLoginclassRctl(r);
}
case Verb::Unknown:
return {404,
std::string("{\"error\":\"unknown or missing 'verb' field\"}")};
Expand Down
9 changes: 9 additions & 0 deletions daemon/privops_handlers.h
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,13 @@ PrivOpsWirePure::DispatchResult handleSetIfaceInetAddr(const PrivOpsPure::SetIfa
// the response body carries the assigned A/B iface names.
PrivOpsWirePure::DispatchResult handleCreateEpair(const PrivOpsPure::CreateEpairReq &r);

// 0.9.28: per-loginclass RCTL umbrella ops. Wraps
// `rctl -a loginclass:<name>:<key>:deny=<value>` and
// `rctl -r loginclass:<name>:<key>:deny`. The set_rctl
// (0.9.0) verb is jail-scoped; this is the umbrella variant
// that aggregates resource use across all of one operator's
// jails.
PrivOpsWirePure::DispatchResult handleSetLoginclassRctl(const PrivOpsPure::SetLoginclassRctlReq &r);
PrivOpsWirePure::DispatchResult handleClearLoginclassRctl(const PrivOpsPure::ClearLoginclassRctlReq &r);

} // namespace Crated
7 changes: 7 additions & 0 deletions lib/privops_client.h
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ PrivOpsNvPure::FieldMap buildSetIfaceInetAddr(const std::string &ifname,
// them via PrivOpsWirePure::extractStringField.
PrivOpsNvPure::FieldMap buildCreateEpair();

// 0.9.28: per-loginclass RCTL umbrella ops.
PrivOpsNvPure::FieldMap buildSetLoginclassRctl(const std::string &loginclass,
const std::string &key,
const std::string &rawValue);
PrivOpsNvPure::FieldMap buildClearLoginclassRctl(const std::string &loginclass,
const std::string &key);

// --- Wire transport (FreeBSD-only) ---

struct Response {
Expand Down
20 changes: 20 additions & 0 deletions lib/privops_client_pure.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -268,4 +268,24 @@ PrivOpsNvPure::FieldMap buildCreateEpair() {
};
}

PrivOpsNvPure::FieldMap buildSetLoginclassRctl(const std::string &loginclass,
const std::string &key,
const std::string &rawValue) {
return {
{"verb", "set_loginclass_rctl"},
{"loginclass", loginclass},
{"key", key},
{"value", rawValue},
};
}

PrivOpsNvPure::FieldMap buildClearLoginclassRctl(const std::string &loginclass,
const std::string &key) {
return {
{"verb", "clear_loginclass_rctl"},
{"loginclass", loginclass},
{"key", key},
};
}

} // namespace PrivOpsClient
15 changes: 15 additions & 0 deletions lib/privops_nv_pure.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,21 @@ std::string parseCreateEpair(const FieldMap &/*m*/,
return "";
}

std::string parseSetLoginclassRctl(const FieldMap &m,
PrivOpsPure::SetLoginclassRctlReq &out) {
if (auto e = requireString(m, "loginclass", out.loginclass); !e.empty()) return e;
if (auto e = requireString(m, "key", out.key); !e.empty()) return e;
if (auto e = requireString(m, "value", out.rawValue); !e.empty()) return e;
return "";
}

std::string parseClearLoginclassRctl(const FieldMap &m,
PrivOpsPure::ClearLoginclassRctlReq &out) {
if (auto e = requireString(m, "loginclass", out.loginclass); !e.empty()) return e;
if (auto e = requireString(m, "key", out.key); !e.empty()) return e;
return "";
}

// --- Verb routing ---

PrivOpsPure::Verb extractVerb(const FieldMap &m) {
Expand Down
4 changes: 4 additions & 0 deletions lib/privops_nv_pure.h
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ std::string parseSetIfaceInetAddr(const FieldMap &m,
PrivOpsPure::SetIfaceInetAddrReq &out);
std::string parseCreateEpair(const FieldMap &m,
PrivOpsPure::CreateEpairReq &out);
std::string parseSetLoginclassRctl(const FieldMap &m,
PrivOpsPure::SetLoginclassRctlReq &out);
std::string parseClearLoginclassRctl(const FieldMap &m,
PrivOpsPure::ClearLoginclassRctlReq &out);

// --- Verb routing ---

Expand Down
20 changes: 20 additions & 0 deletions lib/privops_pure.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include "privops_pure.h"

#include "per_user_rctl_pure.h"
#include "retune_pure.h"

#include <cctype>
Expand Down Expand Up @@ -91,6 +92,8 @@ const char *verbName(Verb v) {
case Verb::BridgeDelMember: return "bridge_del_member";
case Verb::SetIfaceInetAddr: return "set_iface_inet_addr";
case Verb::CreateEpair: return "create_epair";
case Verb::SetLoginclassRctl: return "set_loginclass_rctl";
case Verb::ClearLoginclassRctl: return "clear_loginclass_rctl";
case Verb::Unknown: return "unknown";
}
return "unknown";
Expand All @@ -117,6 +120,8 @@ Verb parseVerb(const std::string &name) {
if (name == "bridge_del_member") return Verb::BridgeDelMember;
if (name == "set_iface_inet_addr") return Verb::SetIfaceInetAddr;
if (name == "create_epair") return Verb::CreateEpair;
if (name == "set_loginclass_rctl") return Verb::SetLoginclassRctl;
if (name == "clear_loginclass_rctl") return Verb::ClearLoginclassRctl;
return Verb::Unknown;
}

Expand Down Expand Up @@ -513,4 +518,19 @@ std::string validateCreateEpair(const CreateEpairReq &) {
return "";
}

std::string validateSetLoginclassRctl(const SetLoginclassRctlReq &r) {
if (auto e = PerUserRctlPure::validateLoginclassName(r.loginclass); !e.empty())
return "loginclass: " + e;
if (auto e = RetunePure::validateRctlKey(r.key); !e.empty()) return e;
if (auto e = RetunePure::validateRctlValue(r.key, r.rawValue); !e.empty()) return e;
return "";
}

std::string validateClearLoginclassRctl(const ClearLoginclassRctlReq &r) {
if (auto e = PerUserRctlPure::validateLoginclassName(r.loginclass); !e.empty())
return "loginclass: " + e;
if (auto e = RetunePure::validateRctlKey(r.key); !e.empty()) return e;
return "";
}

} // namespace PrivOpsPure
27 changes: 27 additions & 0 deletions lib/privops_pure.h
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,17 @@ enum class Verb {
// and setupBridgeEpair (line 396) where the existing code
// unpacks `auto epairPair = IfconfigOps::createEpair();`.
CreateEpair,

// 0.9.28: per-loginclass RCTL rules. Wraps
// `rctl -a loginclass:crate-<uid>:KEY:deny=VAL` semantics
// (PerUserRctlPure::loginclassName generates the loginclass).
// The set_rctl verb (0.9.0) is jail-scoped; this is the
// umbrella variant that aggregates resource use across all
// of a single operator's jails — alice can't exceed her
// total memoryuse cap regardless of how many jails she
// spawns.
SetLoginclassRctl,
ClearLoginclassRctl,
};

// Returns the verb's canonical wire-format token (lowercase, no
Expand Down Expand Up @@ -261,6 +272,20 @@ struct SetIfaceInetAddrReq {
struct CreateEpairReq {
};

// 0.9.28: per-loginclass RCTL rules. Loginclass is the umbrella
// "crate-<uid>" generated by PerUserRctlPure; key + value
// validate via the same RetunePure whitelist as SetRctl.
struct SetLoginclassRctlReq {
std::string loginclass; // e.g. "crate-1000"
std::string key; // RCTL key (validated via RetunePure)
std::string rawValue; // value (validated via RetunePure)
};

struct ClearLoginclassRctlReq {
std::string loginclass;
std::string key;
};

// --- Per-verb validators ---
//
// Each `validate*(req)` returns "" on success, otherwise a one-line
Expand Down Expand Up @@ -289,6 +314,8 @@ std::string validateBridgeAddMember(const BridgeAddMemberReq &r);
std::string validateBridgeDelMember(const BridgeDelMemberReq &r);
std::string validateSetIfaceInetAddr(const SetIfaceInetAddrReq &r);
std::string validateCreateEpair(const CreateEpairReq &r);
std::string validateSetLoginclassRctl(const SetLoginclassRctlReq &r);
std::string validateClearLoginclassRctl(const ClearLoginclassRctlReq &r);

// --- Field-level validators (exposed for tests + reuse) ---
//
Expand Down
Loading
Loading