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
141 changes: 141 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,147 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## [0.9.29] — 2026-05-10

**Rootless track, RCTL umbrella auto-apply.** Thirtieth 0.9.x
release. The `set_loginclass_rctl` primitive from 0.9.28 now
fires automatically after a successful `create_jail` privops
invocation, sourced from a new `rctl_umbrella:` block in
`crated.conf`. Operators no longer need a startup-script step
to seed loginclass quotas.

### What lands

#### Config schema

`Crated::Config` gains:

```cpp
std::vector<std::pair<std::string, std::string>> rctlUmbrella;
```

YAML form:

```yaml
rctl_umbrella:
memoryuse: 4G
pcpu: 200
maxproc: 256
```

Keys / values validated via `RetunePure::validateRctlKey` /
`validateRctlValue` at config-load time — bad entries throw
at daemon startup, not at first jail-create.

#### Process-global rules

`Crated::setUmbrellaConfig(rules)` registers the parsed map
once at daemon startup. `daemon/main.cpp` calls it just
before opening the privops listener.

#### Auto-apply in dispatcher

`maybeApplyUmbrella(verb, uid, status)` runs after every
libnv-transport dispatch. Fires only when:

1. `verb == CreateJail`
2. `uid > 0` (peer credentials known — libnv socket only;
HTTP path always has uid=0 → no-op)
3. `200 <= status < 300` (jail actually created)
4. `g_umbrellaRules` is non-empty

For each rule, runs `rctl -a loginclass:crate-<uid>:KEY:deny=VALUE`
via `Util::execCommand`. Best-effort: failures log to stderr
but do NOT fail the create_jail response — the jail is up,
losing an umbrella rule is a quota gap not a correctness
break.

### Behaviour

```
# crated.conf has:
# rctl_umbrella:
# memoryuse: 4G

# alice (uid 1000) sends create_jail "web1" via libnv socket
# crated:
# 1. handleCreateJail succeeds
# 2. maybeApplyUmbrella fires:
# rctl -a loginclass:crate-1000:memoryuse:deny=4G
# 3. response 200 returned to client

# alice sends create_jail "web2"
# crated:
# 1. handleCreateJail succeeds
# 2. maybeApplyUmbrella re-applies the same rule
# (idempotent — kernel no-ops the duplicate)

# Now alice has 2 jails. Per-jail rules cap each. Umbrella
# cap of 4G applies to alice's TOTAL across both jails.

# bob (uid 1001) sends create_jail
# crated applies rctl -a loginclass:crate-1001:memoryuse:deny=4G
# bob's umbrella is independent of alice's.
```

### HTTP path: no auto-apply

The HTTP transport never has a peer uid (cpp-httplib limitation;
0.9.14 architecture decision). Auto-apply skips on HTTP requests
— bearer-token API isn't multi-tenant in the operator-uid sense.
Operators wanting umbrella enforcement must use the libnv socket
(or call `set_loginclass_rctl` manually).

### Series state

CLI call-sites wired (12+):
- All `crate retune`, `crate stop`, full `crate run` chain
- All host-side iface verbs (createEpair / disableOffload /
setUp / setInetAddr / bridge add+del)
- Lease file per-user path (0.9.27)
- Loginclass RCTL umbrella primitives (0.9.28)
- **Auto-apply umbrella at create_jail (this release)**

The `rootless_per_user: true` config block and per-user
namespacing (paths, ZFS, network sub-CIDR, RCTL groups) are
all wired and tested individually.

Remaining:
- 0.9.30 — default flip (`rootless_per_user: true` becomes
default in `crated.conf.sample`; existing setuid-prod
deployments unaffected, but new installs default to
rootless)
- 1.0.0 — setuid bit removed from `Makefile install` target

### Tests

No new tests — the auto-apply path is daemon-side runtime
behaviour requiring a real `rctl(8)` to exercise meaningfully.
Covered by:
- `rctl_umbrella` config parsing tested implicitly via
daemon startup on FreeBSD CI (bad config = startup throw)
- `maybeApplyUmbrella` logic mirrors `maybeWritePerUserAudit`
shape (proven pattern from 0.9.13)
- `set_loginclass_rctl` validator coverage from 0.9.28
(auto-apply uses the same key/value path)

Suite stays at 1303.

### Files

- `daemon/config.h` — `rctlUmbrella` field
- `daemon/config.cpp` — YAML parser, validates at load time
- `daemon/privops_handlers.{h,cpp}` — `setUmbrellaConfig`
setter + `maybeApplyUmbrella` post-dispatch hook +
`g_umbrellaRules` storage
- `daemon/main.cpp` — `setUmbrellaConfig(config.rctlUmbrella)`
at startup
- `daemon/crated.conf.sample` — `rctl_umbrella:` block
- `cli/args.cpp` — version `crate 0.9.29`
- `CHANGELOG.md` — entry

---

## [0.9.28] — 2026-05-09

**Rootless track, RCTL umbrella verbs.** Twenty-ninth 0.9.x
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.28" << std::endl;
std::cout << "crate 0.9.29" << 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.28" << std::endl;
std::cout << "crate 0.9.29" << std::endl;
exit(0);
default:
err("unsupported short option '%s'", argv[a]);
Expand Down
18 changes: 18 additions & 0 deletions daemon/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include "config.h"
#include "socket_perms_pure.h"
#include "../lib/auth_pure.h"
#include "../lib/retune_pure.h"

#include <yaml-cpp/yaml.h>
#include <stdexcept>
Expand Down Expand Up @@ -201,6 +202,23 @@ Config Config::load(const std::string &path) {
}
}

// 0.9.29: rctl_umbrella block. Map of RCTL key -> value (string).
// Validate against RetunePure whitelist at load time so bad
// entries fail startup, not first jail-create.
if (auto u = root["rctl_umbrella"]) {
if (!u.IsMap())
throw std::runtime_error("rctl_umbrella must be a YAML map");
for (auto kv : u) {
auto key = kv.first.as<std::string>();
auto val = kv.second.as<std::string>();
if (auto e = RetunePure::validateRctlKey(key); !e.empty())
throw std::runtime_error("rctl_umbrella." + key + ": " + e);
if (auto e = RetunePure::validateRctlValue(key, val); !e.empty())
throw std::runtime_error("rctl_umbrella." + key + ": " + e);
cfg.rctlUmbrella.emplace_back(key, val);
}
}

return cfg;
}

Expand Down
17 changes: 17 additions & 0 deletions daemon/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,23 @@ struct Config {
std::string privopsSocketGroup;
unsigned privopsSocketMode = 0660;

// 0.9.29: per-loginclass RCTL umbrella defaults. When non-empty
// AND a create_jail privops verb is invoked over the libnv
// socket (peer uid > 0), the daemon auto-applies these RCTL
// rules to the operator's `crate-<uid>` loginclass after the
// jail is created. Idempotent — re-applying the same rule is
// a no-op at the kernel.
//
// Example crated.conf:
// rctl_umbrella:
// memoryuse: 4G
// pcpu: 200
// maxproc: 256
//
// Keys / values are validated via the existing RetunePure
// whitelist at config-load time; bad entries throw at startup.
std::vector<std::pair<std::string, std::string>> rctlUmbrella;

// Load from YAML file
static Config load(const std::string &path);
};
Expand Down
25 changes: 25 additions & 0 deletions daemon/crated.conf.sample
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,28 @@ log:
# socket: /var/run/crate/crated-privops.sock
# group: crate-operators
# mode: "0660"

##
## --- 0.9.29: per-loginclass RCTL umbrella defaults ---
##
## When a `create_jail` privops verb arrives over the libnv socket
## (peer uid > 0), crated auto-applies these RCTL rules to the
## operator's `crate-<uid>` loginclass after the jail is created.
## Idempotent — re-applying the same rule is a no-op at the kernel.
##
## With multiple jails per operator, the umbrella enforces an
## aggregate cap. If alice spawns 3 jails of 2G memoryuse each
## and the umbrella says memoryuse: 4G, the kernel rejects the
## third allocation (or fires the deny action) once total hits 4G.
##
## Keys / values validated via the same RetunePure whitelist as
## `crate retune --rctl`. Bad entries fail crated startup.
##
## Empty / unset block disables auto-application — the
## set_loginclass_rctl / clear_loginclass_rctl primitives from
## 0.9.28 still work on demand.
#
# rctl_umbrella:
# memoryuse: 4G
# pcpu: 200
# maxproc: 256
6 changes: 6 additions & 0 deletions daemon/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "server.h"
#include "ws_console.h"
#include "control_socket.h"
#include "privops_handlers.h"
#include "privops_listener.h"

#include "err.h"
Expand Down Expand Up @@ -113,6 +114,11 @@ int main(int argc, char **argv) {
int csStarted = controlSockets.start();

// 0.9.14: privops listener (opt-in via privops_socket: in config).
// 0.9.29: register umbrella RCTL rules with the privops
// dispatcher; consulted post-create_jail when the operator's
// uid is known via getpeereid (libnv socket path).
Crated::setUmbrellaConfig(config.rctlUmbrella);

Crated::PrivopsListener privopsListener(config);
bool privopsStarted = privopsListener.start();

Expand Down
43 changes: 43 additions & 0 deletions daemon/privops_handlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,42 @@ void maybeWritePerUserAudit(bool rootlessPerUser, uint32_t uid,
appendPerUserAuditLine(uid, AuditPerUserPure::formatLine(r));
}

// 0.9.29: process-global RCTL umbrella config. Set once at daemon
// startup via setUmbrellaConfig(); read by maybeApplyUmbrella()
// after a successful create_jail privops invocation. Safe for
// concurrent reads after the one-time write at startup.
std::vector<std::pair<std::string, std::string>> g_umbrellaRules;

// Apply umbrella rules to the operator's `crate-<uid>` loginclass
// after a successful create_jail. Only fires when:
// - operator's uid is known (uid > 0; libnv path supplies it,
// HTTP path always has uid=0 → no-op)
// - the verb was create_jail and returned 2xx
// - g_umbrellaRules is non-empty (operator opted in via
// `rctl_umbrella:` in crated.conf)
//
// Best-effort: rctl(8) failures are logged to stderr but do NOT
// fail the create_jail response — the jail is already up; losing
// an umbrella rule is a quota gap, not a correctness break.
void maybeApplyUmbrella(Verb v, uint32_t uid, int status) {
if (v != Verb::CreateJail) return;
if (uid == 0 || status < 200 || status >= 300) return;
if (g_umbrellaRules.empty()) return;
std::string loginclass = "crate-" + std::to_string(uid);
for (const auto &kv : g_umbrellaRules) {
std::string rule = "loginclass:" + loginclass + ":" + kv.first
+ ":deny=" + kv.second;
try {
Util::execCommand({CRATE_PATH_RCTL, "-a", rule},
"privops umbrella");
} catch (const std::exception &e) {
std::fprintf(stderr,
"privops_handlers: umbrella rule '%s' for uid %u failed: %s\n",
rule.c_str(), (unsigned)uid, e.what());
}
}
}

} // anon

DispatchResult dispatchPrivOp(Verb v, const std::string &body,
Expand Down Expand Up @@ -875,7 +911,14 @@ DispatchResult dispatchPrivOpFromMap(const PrivOpsNvPure::FieldMap &m,
std::string("{\"error\":\"unknown or missing 'verb' field\"}")};
}();
maybeWritePerUserAudit(rootlessPerUser, operatorUid, v, result.status);
maybeApplyUmbrella(v, operatorUid, result.status);
return result;
}

// 0.9.29: setter for the process-global umbrella config. daemon/main.cpp
// calls this once at startup with the parsed `rctl_umbrella` block.
void setUmbrellaConfig(const std::vector<std::pair<std::string, std::string>> &rules) {
g_umbrellaRules = rules;
}

} // namespace Crated
10 changes: 10 additions & 0 deletions daemon/privops_handlers.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

#include <cstdint>
#include <string>
#include <utility>
#include <vector>

namespace Crated {

Expand Down Expand Up @@ -175,4 +177,12 @@ PrivOpsWirePure::DispatchResult handleCreateEpair(const PrivOpsPure::CreateEpair
PrivOpsWirePure::DispatchResult handleSetLoginclassRctl(const PrivOpsPure::SetLoginclassRctlReq &r);
PrivOpsWirePure::DispatchResult handleClearLoginclassRctl(const PrivOpsPure::ClearLoginclassRctlReq &r);

// 0.9.29: register the daemon's `rctl_umbrella:` config. Called
// once at daemon startup. The umbrella rules apply after a
// successful create_jail privops invocation when the operator's
// uid is known (libnv socket; HTTP path doesn't have uid). Pass
// an empty vector to disable. Idempotent at the kernel level
// (rctl(8) treats re-set of an existing rule as a no-op).
void setUmbrellaConfig(const std::vector<std::pair<std::string, std::string>> &rules);

} // namespace Crated
Loading