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

---

## [0.7.1] — 2026-05-03

API tokens gain optional **TTL** and **scope** fields — tighten the
blast radius of a leaked CI runner token without rewriting the
auth flow. Inspired by Proxmox's API token model (where each token
carries an explicit expiry and a path-prefix ACL).

### Configuration

```yaml
auth:
tokens:
- name: ci-runner
token_hash: "sha256:..."
role: admin
expires_at: "2026-12-31T23:59:59Z"
scope:
- /api/v1/containers/*
- /api/v1/exports/*
```

`expires_at` is a UTC ISO 8601 timestamp (`Z` or `+00:00`/`-00:00`
suffix; non-UTC offsets rejected). `scope` is a list of path
globs:

- exact match: `/api/v1/host`
- trailing prefix: `/api/v1/containers/*` matches any path that
starts with `/api/v1/containers/`. **The slash is required** —
the bare prefix `/api/v1/containers` does NOT match the glob,
so granting per-resource access doesn't accidentally leak the
collection-list endpoint.

### Backward compatibility

Pre-0.7.1 configs keep working unchanged. Missing `expires_at`
parses to `0`, which `isExpired()` treats as "never expires".
Missing `scope` parses to an empty list, which `pathInScope()`
treats as "no restriction". So an existing token with just
`name`/`token_hash`/`role` has identical behaviour after upgrade.

### Implementation

- **`lib/auth_pure.{h,cpp}`** — extends the pure module with
`parseIso8601Utc(s)` (returns `-1` on bad shape, bad month
range, or non-UTC offset; uses `timegm`), `isExpired(t, now)`
(strict-after; `expiresAt=0` always returns false),
`pathInScope(scope, path)` (empty scope → true; exact match or
`/*`-suffix prefix match), and the combined
`checkBearerAuthFull(...)` which folds expiry + scope into the
hash + role-check sequence.
- **`daemon/config.{h,cpp}`** — `AuthToken` gains
`expiresAt: long` and `scope: vector<string>` (defaults `0` /
empty). YAML parser reads `expires_at:` (rejects malformed) and
`scope:` (scalar or sequence).
- **`daemon/auth.cpp`** — `isAuthorized()` calls
`checkBearerAuthFull` with `req.path` and `time(nullptr)`.
- **`daemon/crated.conf.sample`** — documents the new fields with
a `ci-runner` example.

### Tests

`tests/unit/auth_pure_test.cpp` — 13 new ATF cases on top of the
existing 15:
- 3 `parseIso8601Utc` (canonical epochs at 1970/2000/2026 with
exact UNIX values; invalid-shape rejection incl. bad month
`2026-13-31`; `+00:00`/`-00:00` accepted as UTC)
- 2 `isExpired` (0 means never; strict-after at the boundary —
exact-second still alive)
- 4 `pathInScope` (empty scope unrestricted; exact match without
partial prefix; trailing `/*` glob; **glob requires slash —
bare prefix doesn't match**, the property that gates
per-resource access)
- 4 `checkBearerAuthFull` integration (expired-rejected even with
matching role+scope; out-of-scope-rejected even with matching
role; in-scope admin happy path; backward-compat with
expiresAt=0 + empty scope = pre-0.7.1 behaviour)

**737/737** unit tests expected (was 724; pure tests verified at
28/28 for auth_pure_test alone).

---

## [0.7.0] — 2026-05-03

**0.7 series — enterprise-grade operations.** Inspired by what
Expand Down
6 changes: 6 additions & 0 deletions TODO
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,9 @@ Items completed and removed from this file:
`crate restore STREAM --to <pool/jails/name>` runs `zfs recv`.
Pure module owns plan choice, argv builders, retention parser
(0.7.0 — lib/backup_pure.cpp + lib/backup.cpp).
- API tokens with TTL + scope — crated.conf tokens gain optional
`expires_at:` (UTC ISO 8601) and `scope:` (list of path globs:
exact match or trailing /* prefix). isAuthorized() runs the
expiry + scope check before the role gate. Pre-0.7.1 configs
keep working unchanged: missing fields = never-expires + no
restriction (0.7.1 — lib/auth_pure.cpp + daemon/auth.cpp).
4 changes: 2 additions & 2 deletions cli/args.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ Args parseArguments(int argc, char** argv, unsigned &processed) {
args.noColor = true;
break;
} else if (strEq(argv[a], "--version")) {
std::cout << "crate 0.7.0" << std::endl;
std::cout << "crate 0.7.1" << std::endl;
exit(0);
} else if (auto argShort = isShort(argv[a])) {
switch (argShort) {
Expand All @@ -522,7 +522,7 @@ Args parseArguments(int argc, char** argv, unsigned &processed) {
args.logProgress = true;
break;
case 'V':
std::cout << "crate 0.7.0" << std::endl;
std::cout << "crate 0.7.1" << std::endl;
exit(0);
default:
err("unsupported short option '%s'", argv[a]);
Expand Down
9 changes: 7 additions & 2 deletions daemon/auth.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

#include <openssl/evp.h>

#include <ctime>
#include <iomanip>
#include <sstream>

Expand Down Expand Up @@ -46,11 +47,15 @@ bool isAuthorized(const httplib::Request &req, const Config &config,
if (isUnixSocketPeer(req))
return true;

// Bearer-token check via pure helper (lib/auth_pure.cpp).
return AuthPure::checkBearerAuth(
// Bearer-token check (with TTL + scope) via pure helper.
// Tokens with expiresAt=0 / empty scope are unrestricted, so
// pre-0.7.1 crated.conf files keep working untouched.
return AuthPure::checkBearerAuthFull(
req.get_header_value("Authorization"),
config.tokens,
requiredRole,
req.path,
(long)::time(nullptr),
sha256hex);
}

Expand Down
17 changes: 17 additions & 0 deletions daemon/config.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (C) 2026 by Vladyslav V. Prodan <github.com/click0>. All rights reserved.

#include "config.h"
#include "../lib/auth_pure.h"

#include <yaml-cpp/yaml.h>
#include <stdexcept>
Expand Down Expand Up @@ -38,6 +39,22 @@ Config Config::load(const std::string &path) {
at.name = t["name"].as<std::string>();
at.tokenHash = t["token_hash"].as<std::string>();
at.role = t["role"].as<std::string>("viewer");
if (t["expires_at"]) {
auto raw = t["expires_at"].as<std::string>();
auto parsed = AuthPure::parseIso8601Utc(raw);
if (parsed < 0)
throw std::runtime_error(
"auth.tokens[" + at.name + "].expires_at is not a valid ISO 8601 UTC timestamp: '" + raw + "'");
at.expiresAt = parsed;
}
if (t["scope"]) {
auto sc = t["scope"];
if (sc.IsSequence()) {
for (auto s : sc) at.scope.push_back(s.as<std::string>());
} else if (sc.IsScalar()) {
at.scope.push_back(sc.as<std::string>());
}
}
cfg.tokens.push_back(at);
}
}
Expand Down
9 changes: 9 additions & 0 deletions daemon/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ struct AuthToken {
std::string name;
std::string tokenHash; // "sha256:<hex>"
std::string role; // "admin" or "viewer"
// Expiry: UNIX epoch seconds. 0 means "never expires" — preserves
// backward compatibility with existing crated.conf files that have
// no expires_at field.
long expiresAt = 0;
// Scope: list of glob patterns matched against the request path.
// Empty list means "no restriction" (any path the role allows).
// Patterns: literal path or trailing `/*`. See AuthPure::pathInScope.
// ["/api/v1/containers", "/api/v1/containers/*"]
std::vector<std::string> scope;
};

struct Config {
Expand Down
16 changes: 16 additions & 0 deletions daemon/crated.conf.sample
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ listen:

## API token authentication
## Generate hash: echo -n "your-token" | sha256
##
## Optional per-token fields (added in 0.7.1):
## expires_at — UTC ISO 8601 (Z or +00:00). 0/missing = never expires.
## Tokens loaded from pre-0.7.1 configs are unaffected.
## scope — list of path globs. Empty/missing = no restriction.
## Patterns: "/api/v1/host" (exact),
## "/api/v1/containers/*" (prefix; the slash
## is required so the bare prefix
## "/api/v1/containers" does NOT match).
# auth:
# tokens:
# - name: ansible
Expand All @@ -26,6 +35,13 @@ listen:
# - name: grafana
# token_hash: "sha256:..."
# role: viewer
# - name: ci-runner
# token_hash: "sha256:..."
# role: admin
# expires_at: "2026-12-31T23:59:59Z"
# scope:
# - /api/v1/containers/*
# - /api/v1/exports/*

log:
file: /var/log/crated.log
Expand Down
81 changes: 81 additions & 0 deletions lib/auth_pure.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

#include "auth_pure.h"

#include <cstring>
#include <ctime>

namespace AuthPure {

std::string parseBearerToken(const std::string &authHeader) {
Expand Down Expand Up @@ -37,4 +40,82 @@ bool checkBearerAuth(const std::string &authHeader,
return checkTokenRole(hash, tokens, requiredRole);
}

// --- TTL + scope ---

long parseIso8601Utc(const std::string &s) {
// Accept "YYYY-MM-DDTHH:MM:SSZ" and "YYYY-MM-DDTHH:MM:SS+00:00".
// Reject anything with a non-zero offset — we want UTC only.
std::tm tm{};
// Parse the YMDHMS prefix (19 chars).
if (s.size() < 20) return -1;
if (s[4] != '-' || s[7] != '-' || s[10] != 'T'
|| s[13] != ':' || s[16] != ':')
return -1;
for (int i : {0,1,2,3,5,6,8,9,11,12,14,15,17,18}) {
if (s[i] < '0' || s[i] > '9') return -1;
}
tm.tm_year = std::atoi(s.substr(0, 4).c_str()) - 1900;
tm.tm_mon = std::atoi(s.substr(5, 2).c_str()) - 1;
tm.tm_mday = std::atoi(s.substr(8, 2).c_str());
tm.tm_hour = std::atoi(s.substr(11, 2).c_str());
tm.tm_min = std::atoi(s.substr(14, 2).c_str());
tm.tm_sec = std::atoi(s.substr(17, 2).c_str());
// Sanity-check ranges before timegm so 99-99-99 etc. are rejected.
if (tm.tm_mon < 0 || tm.tm_mon > 11) return -1;
if (tm.tm_mday < 1 || tm.tm_mday > 31) return -1;
if (tm.tm_hour < 0 || tm.tm_hour > 23) return -1;
if (tm.tm_min < 0 || tm.tm_min > 59) return -1;
if (tm.tm_sec < 0 || tm.tm_sec > 60) return -1;
// Timezone suffix.
auto tz = s.substr(19);
if (tz != "Z" && tz != "+00:00" && tz != "-00:00") return -1;
long t = (long)::timegm(&tm);
return t;
}

bool isExpired(const Crated::AuthToken &t, long now) {
if (t.expiresAt == 0) return false; // 0 == "never expires"
return now > t.expiresAt;
}

bool pathInScope(const std::vector<std::string> &scope,
const std::string &path) {
if (scope.empty()) return true; // empty == no restriction
for (auto &p : scope) {
if (p.empty()) continue;
// Trailing-glob: pattern ending in "/*" matches any path that
// starts with the prefix INCLUDING the slash. So "/api/v1/foo/*"
// matches "/api/v1/foo/bar" but NOT "/api/v1/foo".
if (p.size() >= 2 && p.compare(p.size() - 2, 2, "/*") == 0) {
auto prefix = p.substr(0, p.size() - 1); // keep trailing '/'
if (path.size() > prefix.size()
&& path.compare(0, prefix.size(), prefix) == 0)
return true;
continue;
}
if (path == p) return true;
}
return false;
}

bool checkBearerAuthFull(const std::string &authHeader,
const std::vector<Crated::AuthToken> &tokens,
const std::string &requiredRole,
const std::string &path,
long now,
const std::function<std::string(const std::string&)> &sha256Fn) {
auto token = parseBearerToken(authHeader);
if (token.empty()) return false;
auto hash = sha256Fn(token);
// Find matching entry to check expiry + scope before returning.
for (auto &t : tokens) {
if (t.tokenHash != hash) continue;
if (isExpired(t, now)) return false;
if (!pathInScope(t.scope, path)) return false;
if (requiredRole == "viewer") return true;
return t.role == requiredRole || t.role == "admin";
}
return false;
}

}
32 changes: 32 additions & 0 deletions lib/auth_pure.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,36 @@ bool checkBearerAuth(const std::string &authHeader,
const std::string &requiredRole,
const std::function<std::string(const std::string&)> &sha256Fn);

// --- TTL + scope (added in 0.7.1) ---

// Parse an ISO 8601 UTC timestamp ("YYYY-MM-DDTHH:MM:SSZ" or
// "YYYY-MM-DDTHH:MM:SS+00:00") into a UNIX epoch. Returns -1 on
// any parse error. Pure — uses timegm via the standard library.
long parseIso8601Utc(const std::string &s);

// True iff `now` is strictly after the token's expiry. Tokens with
// expiresAt == 0 are treated as "never expires" so existing
// crated.conf files keep working untouched.
bool isExpired(const Crated::AuthToken &t, long now);

// True iff at least one entry in `scope` matches `path`. An empty
// `scope` list means "no restriction" (always true). Patterns:
// - exact match: "/api/v1/host"
// - trailing wild: "/api/v1/containers/*" matches any path that
// starts with "/api/v1/containers/" (note: the
// slash is required so the bare prefix
// "/api/v1/containers" does NOT match this glob).
bool pathInScope(const std::vector<std::string> &scope,
const std::string &path);

// Combined: parse + hash + role-check + expiry-check + scope-check.
// `now` is the current UNIX epoch (injectable for tests). `path` is
// the request path the daemon is gating; used for scope matching.
bool checkBearerAuthFull(const std::string &authHeader,
const std::vector<Crated::AuthToken> &tokens,
const std::string &requiredRole,
const std::string &path,
long now,
const std::function<std::string(const std::string&)> &sha256Fn);

}
2 changes: 1 addition & 1 deletion port/Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
PORTNAME= crate
PORTVERSION= 0.7.0
PORTVERSION= 0.7.1
CATEGORIES= sysutils
MASTER_SITES= GH
GH_ACCOUNT= click0
Expand Down
2 changes: 1 addition & 1 deletion snmpd/mib.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ static std::mutex g_mibMutex;
static std::vector<ContainerMetrics> g_containers;
static unsigned g_totalCount = 0;
static unsigned g_runningCount = 0;
static std::string g_version = "0.7.0";
static std::string g_version = "0.7.1";
static std::string g_hostname;

// --- AgentX PDU helpers ---
Expand Down
Loading
Loading