use std::sync::{Arc, Mutex};
use gunnar_common::monetize_key::MonetizePubkey;
use gunnar_core::{Entitlement, EntitlementState, EntitlementStore};
use gunnar_proto::EntitlementFact as ProtoFact;
use monetize_embed::{Action, EntitlementCache, State, TenantId, Verdict};
use crate::attest::{AttestationLog, ENTITLEMENT_KEY};
use crate::stores::ServerStores;
const NO_CACHE_BUDGET_BYTES: u64 = 1;
#[derive(Clone, Debug)]
pub struct MeteredConfig {
pub monetize_pubkey: Option<[u8; 32]>,
pub billing_url: String,
pub vital: bool,
}
impl MeteredConfig {
pub fn load(key: &MonetizePubkey, billing_url: Option<&str>) -> anyhow::Result<Self> {
let billing_url = billing_url.ok_or_else(|| {
anyhow::anyhow!(
"--monetize-pubkey was given, but --billing-url was not, and this build has no \
default for it: it would have to invent the page your lapsed tenants are sent \
to. Every refusal and every Grace warning ends in that link, so a metered \
appliance without one refuses tenants with no way out. Pass \
--billing-url <URL> (or GUNNAR_BILLING_URL), with `{{tenant}}` where the \
namespace goes."
)
})?;
let monetize_pubkey = read_pubkey(key)?;
Ok(MeteredConfig {
monetize_pubkey: Some(monetize_pubkey),
billing_url: billing_template(billing_url),
vital: true,
})
}
pub fn awaiting_feed(billing_url: Option<&str>) -> anyhow::Result<Self> {
let billing_url = billing_url.ok_or_else(|| {
anyhow::anyhow!(
"--monetize-pubkey {} (the installer feeds the key) was given, but --billing-url was not, and this \
build has no default for it: the fed key could never be enforced with a pay page. Pass \
--billing-url <URL> (or GUNNAR_BILLING_URL), with `{{tenant}}` where the namespace goes.",
gunnar_common::monetize_key::FED
)
})?;
Ok(MeteredConfig {
monetize_pubkey: None,
billing_url: billing_template(billing_url),
vital: true,
})
}
}
pub fn read_pubkey(key: &MonetizePubkey) -> anyhow::Result<[u8; 32]> {
Ok(match key {
MonetizePubkey::Fed => anyhow::bail!(
"--monetize-pubkey {} names no key: the installer's door feeds it (gunnar-server resolves \
`fed` against <data>/pairing/joined.toml before it ever reads a key)",
gunnar_common::monetize_key::FED
),
MonetizePubkey::Literal(k) => *k,
MonetizePubkey::Path(path) => {
let bytes = std::fs::read(path).map_err(|err| {
anyhow::anyhow!("reading the monetize public key {}: {err}", path.display())
})?;
parse_pubkey(&bytes).ok_or_else(|| {
anyhow::anyhow!(
"{} is not an Ed25519 public key: expected 32 raw bytes, 64 hex \
characters, or `ed25519:<base64>` ({} byte(s) read)",
path.display(),
bytes.len()
)
})?
}
})
}
fn billing_template(url: &str) -> String {
if url.contains("{tenant}") {
url.to_owned()
} else {
format!("{}/{{tenant}}", url.trim_end_matches('/'))
}
}
fn parse_pubkey(bytes: &[u8]) -> Option<[u8; 32]> {
if bytes.len() == 32 {
return bytes.try_into().ok();
}
let text = std::str::from_utf8(bytes).ok()?.trim();
if let Some(b64) = text.strip_prefix("ed25519:") {
return decode_base64(b64.trim())?.try_into().ok();
}
gunnar_common::monetize_key::parse_hex32(text).ok()
}
fn decode_base64(text: &str) -> Option<Vec<u8>> {
fn value(c: u8) -> Option<u32> {
Some(match c {
b'A'..=b'Z' => (c - b'A') as u32,
b'a'..=b'z' => (c - b'a') as u32 + 26,
b'0'..=b'9' => (c - b'0') as u32 + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
_ => return None,
})
}
let mut out = Vec::with_capacity(text.len() * 3 / 4);
let mut acc = 0u32;
let mut bits = 0u32;
for c in text.bytes().filter(|c| *c != b'=') {
acc = (acc << 6) | value(c)?;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
Some(out)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Restated {
pub applied: usize,
pub held: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum SetRefused {
#[error("the fact for {namespace} does not verify under the configured monetize key ({why}); refused, nothing moved")]
Forged { namespace: String, why: String },
#[error("invalid fact: {0}")]
Malformed(String),
#[error("the fact for {namespace} verified but could not be recorded: {why}")]
NotRecorded { namespace: String, why: String },
#[error("the fact for {namespace} is not newer than the one this server already holds for it; refused as a replay, nothing moved")]
Replayed { namespace: String },
#[error("the fact for {namespace} carries no issue time (issued_unix_ms); an unstamped fact cannot be told from a replay and is refused, nothing moved")]
Unstamped { namespace: String },
#[error("the fact for {namespace} cannot be checked: this box has no monetize key yet (the installer feeds it); refused, nothing moved")]
NoKey { namespace: String },
#[error("the fact for {namespace} is behind this box: it already holds a verdict paid until {held_until} by `{held_source}`, and this one pays only until {offered_until} by `{offered_source}` — a monetize restored from an older backup; refused, nothing moved")]
Behind {
namespace: String,
held_until: u64,
held_source: String,
offered_until: u64,
offered_source: String,
},
}
pub use crate::dashboard::TenantCounts;
#[derive(Debug, Clone, Default)]
struct Refusal {
namespaces: Vec<String>,
at_unix_ms: i64,
reason: String,
}
fn now_unix_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
pub const KEY_MISMATCH_ALARM: &str = "entitlement.key_mismatch";
struct Cache(std::sync::RwLock<Option<Arc<EntitlementCache>>>);
impl Cache {
fn current(&self) -> Option<Arc<EntitlementCache>> {
self.0.read().unwrap_or_else(|p| p.into_inner()).clone()
}
fn swap_in(&self, cache: Arc<EntitlementCache>) {
*self.0.write().unwrap_or_else(|p| p.into_inner()) = Some(cache);
}
fn len(&self) -> usize {
self.current().map_or(0, |c| c.len())
}
fn fact(&self, tenant: &TenantId) -> Option<monetize_embed::EntitlementFact> {
self.current()?.fact(tenant)
}
fn allows(&self, tenant: &TenantId, action: Action) -> Verdict {
match self.current() {
Some(c) => c.allows(tenant, action),
None => Verdict::Allow,
}
}
fn notice(&self, tenant: &TenantId) -> Option<String> {
self.current()?.notice(tenant)
}
}
pub struct EntitlementSlot {
cache: Cache,
store: EntitlementStore,
log: AttestationLog,
stores: Arc<ServerStores>,
billing_url: std::sync::RwLock<String>,
refused: std::sync::RwLock<Refusal>,
write: Mutex<()>,
vital: bool,
verified_since_boot: std::sync::atomic::AtomicBool,
sells_room: Arc<std::sync::atomic::AtomicBool>,
}
impl std::fmt::Debug for EntitlementSlot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EntitlementSlot")
.field("tenants", &self.cache.len())
.field("log", &self.log.path())
.finish()
}
}
impl EntitlementSlot {
pub fn open(stores: Arc<ServerStores>, config: &MeteredConfig) -> anyhow::Result<Arc<Self>> {
let store = EntitlementStore::new(Arc::clone(stores.control_store()))?;
let log = AttestationLog::under(stores.root());
let slot = EntitlementSlot {
cache: Cache(std::sync::RwLock::new(None)),
store,
log,
stores,
billing_url: std::sync::RwLock::new(config.billing_url.clone()),
refused: std::sync::RwLock::new(Refusal::default()),
write: Mutex::new(()),
vital: config.vital,
verified_since_boot: std::sync::atomic::AtomicBool::new(false),
sells_room: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
match &config.monetize_pubkey {
Some(key) => slot.load_under(key)?,
None => tracing::warn!(
target: "gunnar::entitlement",
event = "entitlement.awaiting_key",
billing = %config.billing_url,
"metered: the monetize key is FED by the installer and has not arrived — every namespace is Free and \
Entitlement.Set refuses every fact until it does; it then applies in place, with no restart"
),
}
Ok(Arc::new(slot))
}
fn load_under(&self, key: &[u8; 32]) -> anyhow::Result<()> {
let cache = Arc::new(
EntitlementCache::new(key, &self.billing_url()).map_err(|_| {
anyhow::anyhow!("the monetize public key is not a valid Ed25519 point")
})?,
);
let mut loaded = 0usize;
let mut refused = 0usize;
let mut refusal = Refusal {
at_unix_ms: now_unix_ms(),
..Refusal::default()
};
for stored in self.store.all()? {
let namespace = stored.namespace.clone();
match cache.push(to_monetize(&stored)) {
Ok(()) => loaded += 1,
Err(err) => {
refused += 1;
refusal.namespaces.push(namespace.clone());
if refusal.reason.is_empty() {
refusal.reason = format!(
"does not verify under the configured monetize public key ({err}); the likeliest cause after a restore is the wrong --monetize-pubkey"
);
}
tracing::error!(
target: "gunnar::entitlement",
event = "entitlement.boot.refused",
%namespace,
state = %stored.state,
error = %err,
"a stored entitlement fact does not verify under the configured monetize key; \
the namespace is Free until a fact that verifies arrives"
)
}
}
}
if refused > 0 {
tracing::warn!(
target: "gunnar::entitlement",
event = "entitlement.boot.mismatch",
tenants = loaded,
refused,
billing = %self.billing_url(),
"THIS BOX IS SERVING {refused} NAMESPACE(S) WITH A SIGNED FACT AS FREE: their facts are on disk and \
not one of them verifies under the monetize public key this box was given. Nothing on \
the box is broken — the KEY is the wrong one. After a restore from a twin that is the \
likeliest cause, and the fix is the right --monetize-pubkey, never a re-push"
);
}
tracing::info!(
target: "gunnar::entitlement",
event = "entitlement.boot",
tenants = loaded,
refused,
billing = %self.billing_url(),
"metered: entitlement facts loaded"
);
*self.refused.write().unwrap_or_else(|p| p.into_inner()) = refusal;
self.cache.swap_in(cache);
Ok(())
}
pub fn set_key(&self, key: &[u8; 32]) -> Result<bool, String> {
let _serial = self.write.lock().unwrap_or_else(|e| e.into_inner());
if let Some(current) = self.cache.current() {
return if current.public_key().as_bytes() == key {
Ok(false)
} else {
Err(format!(
"REFUSED [{}] this box already enforces monetize key {} and was handed {}; it will not switch which monetize it bills for while running",
crate::feed::FEED_MISMATCH,
hex32(current.public_key().as_bytes()),
hex32(key)
))
};
}
self.load_under(key).map_err(|e| format!("{e:#}"))?;
tracing::info!(target: "gunnar::entitlement", event = "entitlement.key_applied", key = %hex32(key), "metered: the fed monetize key is IN FORCE — applied in place, no restart");
Ok(true)
}
pub fn restate(&self, snap: &monetize_embed::signing::Snapshot) -> Result<Restated, String> {
let Some(cache) = self.cache.current() else {
return if snap.facts.is_empty() {
Ok(Restated::default())
} else {
Err(format!("this box enforces no monetize key yet, so the snapshot's {} entitlement facts cannot be verified; nothing restated", snap.facts.len()))
};
};
monetize_embed::signing::verify_snapshot(snap, cache.public_key())
.map_err(|e| format!("the snapshot does not verify under this box's monetize key ({e}); refused whole, nothing restated"))?;
let mut done = Restated::default();
for fact in &snap.facts {
let held = self.store.get(&fact.tenant.0).ok().flatten();
if held.is_some_and(|h| {
h.signature == fact.signature && h.issued_signature == fact.issued_signature
}) {
done.held += 1;
continue;
}
match self.set(monetize_to_proto(fact)) {
Ok(_) => done.applied += 1,
Err(SetRefused::Replayed { .. } | SetRefused::Behind { .. }) => done.held += 1,
Err(e) => {
return Err(format!(
"restated {} entitlement facts, then stopped: {e}",
done.applied
))
}
}
}
tracing::info!(target: "gunnar::entitlement", event = "entitlement.restated", applied = done.applied, held = done.held, snapshot_issued_unix_ms = snap.issued_unix_ms, "monetize's snapshot restated");
Ok(done)
}
pub fn keyed(&self) -> bool {
self.cache.current().is_some()
}
pub fn push(
&self,
fact: &monetize_embed::EntitlementFact,
) -> Result<(EntitlementState, u64), SetRefused> {
self.set(monetize_to_proto(fact))
}
pub fn set(&self, fact: ProtoFact) -> Result<(EntitlementState, u64), SetRefused> {
let namespace = fact.namespace.clone();
let state = state_from_proto(fact.state()).ok_or_else(|| {
SetRefused::Malformed(format!(
"state {} is not one of Free, Paid, Grace, Suspended, Retention",
fact.state
))
})?;
let monetize = monetize_embed::EntitlementFact {
tenant: TenantId(fact.namespace.clone()),
plan: fact.plan.clone(),
state: to_monetize_state(state),
paid_until_unix_ms: fact.paid_until_unix_ms,
caps: fact.caps.clone(),
source: fact.source.clone(),
signature: fact.signature.clone(),
issued_unix_ms: fact.issued_unix_ms,
issued_signature: fact.issued_signature.clone(),
};
let cache = self.cache.current().ok_or_else(|| SetRefused::NoKey {
namespace: namespace.clone(),
})?;
monetize_embed::signing::verify_fact(&monetize, cache.public_key()).map_err(|err| {
SetRefused::Forged {
namespace: namespace.clone(),
why: err.to_string(),
}
})?;
let accepted_at_unix_ms = gunnar_core::now_ms();
let stored = Entitlement {
namespace: namespace.clone(),
plan: fact.plan,
state,
paid_until_unix_ms: fact.paid_until_unix_ms,
caps: fact.caps,
source: fact.source,
signature: fact.signature,
issued_unix_ms: fact.issued_unix_ms,
issued_signature: fact.issued_signature,
accepted_at_unix_ms,
};
let _serial = self.write.lock().unwrap_or_else(|e| e.into_inner());
let held = self.store.get(&namespace).ok().flatten();
self.check_not_replayed(&namespace, held.as_ref(), &stored)?;
let line = serde_json::json!({
ENTITLEMENT_KEY: monetize,
"accepted_at_unix_ms": accepted_at_unix_ms,
});
let seq =
self.log
.append_line(&line.to_string())
.map_err(|err| SetRefused::NotRecorded {
namespace: namespace.clone(),
why: format!("appending to {}: {err:#}", self.log.path().display()),
})?;
self.store
.set(stored.clone())
.map_err(|err| SetRefused::NotRecorded {
namespace: namespace.clone(),
why: format!("writing the control document: {err}"),
})?;
if let Err(err) = cache.push(to_monetize(&stored)) {
tracing::error!(
target: "gunnar::entitlement",
%namespace,
error = %err,
"a fact that verified in step 1 was refused by the cache in step 4"
);
}
self.enforce_cache_for_state(&namespace, state, stored.cache_cap());
tracing::info!(
target: "gunnar::entitlement",
event = "entitlement.set",
%namespace,
state = %state,
plan = %stored.plan,
source = %stored.source,
seq,
"entitlement fact accepted"
);
if !self
.verified_since_boot
.swap(true, std::sync::atomic::Ordering::SeqCst)
&& self.vital
&& !self.refused().namespaces.is_empty()
{
tracing::warn!(
target: "gunnar::entitlement",
event = "entitlement.key_mismatch.cleared",
%namespace,
"a fact VERIFIES under this box's monetize key: the key mismatch at boot is over, the alarm and the maintenance status clear"
);
}
Ok((state, seq))
}
fn check_not_replayed(
&self,
namespace: &str,
held: Option<&Entitlement>,
offered: &Entitlement,
) -> Result<(), SetRefused> {
let refuse = || {
Err(SetRefused::Replayed {
namespace: namespace.to_owned(),
})
};
if let (Some(h), Some(offered_until)) = (held, offered.paid_until_unix_ms) {
let now = gunnar_core::now_ms().max(0) as u64;
if let Some(held_until) = h
.paid_until_unix_ms
.filter(|u| *u > now && offered_until < *u)
{
if h.state == EntitlementState::Paid && offered.source != h.source {
tracing::warn!(target: "gunnar::entitlement", event = "entitlement.behind", %namespace, held_until, offered_until, held_source = %h.source, offered_source = %offered.source, "a fact behind this box (a restored monetize lost a payment it holds); refused");
return Err(SetRefused::Behind {
namespace: namespace.to_owned(),
held_until,
held_source: h.source.clone(),
offered_until,
offered_source: offered.source.clone(),
});
}
}
}
let Some(issued) = offered.issued_unix_ms else {
tracing::warn!(
target: "gunnar::entitlement",
event = "entitlement.replay_guard.refused",
%namespace,
why = "unstamped",
held_issued_unix_ms = held.and_then(|h| h.issued_unix_ms),
"a fact with no issue time; refused — nothing tells it from a replay"
);
return Err(SetRefused::Unstamped {
namespace: namespace.to_owned(),
});
};
let Some(high_water) = held.and_then(|h| h.issued_unix_ms) else {
tracing::info!(
target: "gunnar::entitlement",
event = "entitlement.replay_guard.armed",
%namespace,
issued_unix_ms = issued,
"the first stamped fact for this namespace: replays of anything older are \
refused from here on"
);
return Ok(());
};
if issued < high_water || (issued == high_water && !same_fact(held, offered)) {
tracing::warn!(
target: "gunnar::entitlement",
event = "entitlement.replay_guard.refused",
%namespace,
why = if issued < high_water { "older" } else { "same instant, different fact" },
offered_issued_unix_ms = issued,
held_issued_unix_ms = high_water,
state = %offered.state,
source = %offered.source,
"a fact that is not newer than the one this server holds; refused as a replay"
);
return refuse();
}
let now = gunnar_core::now_ms();
if now > 0 && issued > (now as u64).saturating_add(MAX_ISSUED_AHEAD_MS) {
tracing::warn!(
target: "gunnar::entitlement",
event = "entitlement.issued.ahead",
%namespace,
issued_unix_ms = issued,
now_unix_ms = now,
"ACCEPTED a fact issued far ahead of this server's clock — service is never \
withdrawn over a clock — but no later fact can beat this stamp, so the \
namespace is now pinned to it. Check monetize's clock."
);
}
Ok(())
}
fn enforce_cache_for_state(
&self,
namespace: &str,
state: EntitlementState,
cache_cap: Option<u64>,
) {
match state {
EntitlementState::Suspended | EntitlementState::Retention => {
self.write_explode_budget(namespace, Some(NO_CACHE_BUDGET_BYTES));
self.reclaim_explode_caches(namespace, state);
}
EntitlementState::Free | EntitlementState::Paid | EntitlementState::Grace => {
match cache_cap {
Some(cache_bytes) => self.apply_cache_cap(namespace, cache_bytes),
None => {
if matches!(
self.stores.catalog().namespace_explode_budget(namespace),
Ok(Some(NO_CACHE_BUDGET_BYTES))
) {
self.write_explode_budget(namespace, None);
self.reopen_on_server_default(namespace);
}
}
}
}
}
}
fn reopen_on_server_default(&self, namespace: &str) {
for repo in self
.stores
.catalog()
.list(Some(namespace))
.unwrap_or_default()
{
self.stores.close_repo(&repo.path());
}
}
fn reclaim_explode_caches(&self, namespace: &str, state: EntitlementState) {
let engine = self.stores.engine();
let mut freed = 0u64;
let mut repos = 0usize;
let mut reclaimed_repos = 0usize;
for repo in self
.stores
.catalog()
.list(Some(namespace))
.unwrap_or_default()
{
let Ok(dir) = self.stores.repo_dir(&repo.path()) else {
continue;
};
repos += 1;
let bytes = engine.drop_explode_cache(&dir);
if bytes > 0 {
reclaimed_repos += 1;
freed += bytes;
}
}
let line = serde_json::json!({
crate::attest::RECLAIM_KEY: {
"namespace": namespace,
"state": state.to_string(),
"bytes_freed": freed,
"repos": repos,
"repos_reclaimed": reclaimed_repos,
"budget_bytes": NO_CACHE_BUDGET_BYTES,
},
"reclaimed_at_unix_ms": gunnar_core::now_ms(),
});
if let Err(err) = self.log.append_line(&line.to_string()) {
tracing::error!(
target: "gunnar::entitlement",
%namespace,
error = %err,
"the cache reclaim could not be journalled; the bytes are gone but the line is not there"
);
}
tracing::info!(
target: "gunnar::entitlement",
event = "entitlement.cache_reclaimed",
%namespace,
state = %state,
bytes = freed,
repos,
repos_reclaimed = reclaimed_repos,
"the derived read cache of a namespace the door refuses was given back"
);
}
fn apply_cache_cap(&self, namespace: &str, cache_bytes: u64) {
self.write_explode_budget(namespace, Some(cache_bytes));
}
fn write_explode_budget(&self, namespace: &str, budget: Option<u64>) {
match self
.stores
.catalog()
.set_namespace_explode_budget(namespace, budget)
{
Ok(_) => {
for repo in self
.stores
.catalog()
.list(Some(namespace))
.unwrap_or_default()
{
if let Ok(dir) = self.stores.repo_dir(&repo.path()) {
self.stores.apply_explode_quota(namespace, &dir);
}
}
}
Err(gunnar_core::Error::NoSuchNamespace(_)) => {}
Err(err) => tracing::warn!(
target: "gunnar::entitlement",
%namespace,
error = %err,
"caps[cache_bytes] could not be applied to the namespace"
),
}
}
pub fn get(&self, namespace: &str) -> anyhow::Result<Option<ProtoFact>> {
Ok(self
.cache
.fact(&TenantId(namespace.to_owned()))
.as_ref()
.map(monetize_to_proto))
}
pub fn list(&self) -> anyhow::Result<Vec<ProtoFact>> {
Ok(self
.store
.all()?
.iter()
.filter_map(|stored| self.cache.fact(&TenantId(stored.namespace.clone())))
.map(|fact| monetize_to_proto(&fact))
.collect())
}
fn refused(&self) -> Refusal {
self.refused
.read()
.unwrap_or_else(|p| p.into_inner())
.clone()
}
pub fn refused_at_boot(&self) -> usize {
self.refused().namespaces.len()
}
pub fn refusal(&self) -> Option<gunnar_proto::RefusedAtBoot> {
(!self.refused().namespaces.is_empty()).then(|| gunnar_proto::RefusedAtBoot {
count: self.refused().namespaces.len() as u64,
at_unix_ms: self.refused().at_unix_ms,
reason: self.refused().reason.clone(),
namespaces: self.refused().namespaces.clone(),
})
}
pub fn maintenance(&self) -> bool {
self.vital
&& !self.refused().namespaces.is_empty()
&& !self
.verified_since_boot
.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn alarm(&self) -> Option<gunnar_proto::HealthAlarm> {
if !self.maintenance() {
return None;
}
self.refusal().map(|r| gunnar_proto::HealthAlarm {
event: KEY_MISMATCH_ALARM.into(),
count: r.count,
since_unix_ms: r.at_unix_ms,
detail: format!(
"MAINTENANCE: {} namespace(s) hold a signed entitlement this box does not verify, and are served as Free: {}: {}. \
It clears when monetize pushes a fact that verifies. The fix is the right --monetize-pubkey, never a re-push",
r.count,
r.namespaces.join(", "),
r.reason
),
})
}
pub fn enforced_state(&self, namespace: &str) -> Option<EntitlementState> {
self.cache
.fact(&TenantId(namespace.to_owned()))
.map(|fact| from_monetize_state(fact.state))
}
pub fn counts(&self) -> TenantCounts {
let mut counts = TenantCounts::default();
for fact in self.store.all().unwrap_or_default() {
match self.enforced_state(&fact.namespace) {
Some(EntitlementState::Paid) => counts.paid += 1,
Some(EntitlementState::Grace) => counts.grace += 1,
Some(EntitlementState::Suspended | EntitlementState::Retention) => {
counts.suspended += 1
}
Some(EntitlementState::Free) | None => {}
}
}
counts
}
fn url_for(&self, namespace: &str) -> String {
self.billing_url().replace("{tenant}", namespace)
}
pub fn billing_url(&self) -> String {
self.billing_url
.read()
.unwrap_or_else(|p| p.into_inner())
.clone()
}
pub fn set_billing_url(&self, url: &str) -> Result<bool, String> {
let url = url.trim();
let host = url
.strip_prefix("https://")
.map(|r| r.split('/').next().unwrap_or_default())
.unwrap_or_default();
if host.is_empty() || url.chars().any(char::is_whitespace) {
return Err(format!("REFUSED [pairing-feed-billing-url] the installer fed `{}` = `{url}`, which is not an https:// page; the pay page in force stays", crate::feed::FEED_BILLING_URL));
}
let url = billing_template(url);
let _serial = self.write.lock().unwrap_or_else(|e| e.into_inner());
if self.billing_url() == url {
return Ok(false);
}
*self.billing_url.write().unwrap_or_else(|p| p.into_inner()) = url.clone();
if let Some(current) = self.cache.current() {
let key = *current.public_key().as_bytes();
self.load_under(&key).map_err(|e| format!("the pay page {url} is set, but the cache did not rebuild under the key in force: {e:#}"))?;
}
tracing::info!(target: "gunnar::entitlement", event = "entitlement.billing_url", billing = %url, "the site's pay page is fed: every refusal and Grace line ends in it");
Ok(true)
}
}
impl EntitlementSlot {
fn charge_refusal(&self, namespace: &str) {
self.stores
.flow()
.refuse(namespace, gunnar_core::now_ms(), gunnar_core::Refusal::Cap);
}
}
impl gunnar_ssh::PlanGate for EntitlementSlot {
fn verdict(&self, namespace: &str, action: gunnar_ssh::PlanAction) -> gunnar_ssh::PlanVerdict {
let tenant = TenantId(namespace.to_owned());
let action = match action {
gunnar_ssh::PlanAction::Write => Action::Write,
gunnar_ssh::PlanAction::AnonymousRead => Action::AnonymousRead,
};
match self.cache.allows(&tenant, action) {
Verdict::Refuse { reason_line, .. } => {
self.charge_refusal(namespace);
gunnar_ssh::PlanVerdict::Refuse(reason_line)
}
Verdict::Allow => match self.cache.notice(&tenant) {
Some(line) => gunnar_ssh::PlanVerdict::Warn(line),
None => gunnar_ssh::PlanVerdict::Allow,
},
}
}
fn name(&self) -> &'static str {
"entitlement"
}
fn pack_quota(&self, namespace: &str) -> gunnar_ssh::PlanVerdict {
let Some(quota) = self.pack_quota_state(namespace) else {
return gunnar_ssh::PlanVerdict::Allow;
};
if quota.plan == gunnar_grpc::selling::NO_ROOM_YET {
self.charge_refusal(namespace);
return gunnar_ssh::PlanVerdict::Refuse(format!(
"{}: {}",
gunnar_grpc::selling::NO_ROOM_YET,
self.url_for(namespace)
));
}
if quota.held >= quota.cap {
self.charge_refusal(namespace);
gunnar_ssh::PlanVerdict::Refuse(format!(
"plan {} is at its pack quota ({} of {} bytes held): writes closed; \
delete or buy more at {}",
quota.plan,
quota.held,
quota.cap,
self.url_for(namespace)
))
} else {
gunnar_ssh::PlanVerdict::Allow
}
}
fn pack_ceiling(&self, namespace: &str) -> Option<gunnar_ssh::PackCeiling> {
let quota = self.pack_quota_state(namespace)?;
let remaining = quota.cap.saturating_sub(quota.held);
let slack = (quota.cap / PACK_CEILING_SLACK_DIVISOR).max(PACK_CEILING_SLACK_FLOOR_BYTES);
let bytes = remaining.saturating_add(slack);
Some(gunnar_ssh::PackCeiling {
bytes,
refusal: format!(
"plan {} would pass its pack quota with this push (cap {} bytes, {} already \
held, this push sent more than {bytes} bytes): the transfer was stopped and \
nothing was stored; delete or buy more at {}",
quota.plan,
quota.cap,
quota.held,
self.url_for(namespace)
),
})
}
fn lfs_allowance(&self, namespace: &str) -> Option<gunnar_lfs::Allowance> {
self.lfs_allowance_state(namespace)
}
}
impl EntitlementSlot {
fn lfs_allowance_state(&self, namespace: &str) -> Option<gunnar_lfs::Allowance> {
if let Ok(Some(clan)) = self.stores.auth().clan(namespace) {
let (pack, lfs) = self.namespace_bytes(namespace);
let room = clan.room_in_force(&self.lapsed_members(&clan));
let held = pack.saturating_add(lfs);
return Some(gunnar_lfs::Allowance {
bytes: room.saturating_sub(held),
refusal: format!(
"clan {namespace} has {} bytes of room left ({room} bytes given by its members, {held} \
already held in packs and LFS objects): this upload does not fit; its members give it \
more room",
room.saturating_sub(held)
),
});
}
let tenant = TenantId(namespace.to_owned());
let fact = self.cache.fact(&tenant)?;
let cap = fact.caps.get("lfs_bytes").copied().filter(|cap| *cap > 0)?;
let held: u64 = self
.stores
.catalog()
.list(Some(namespace))
.unwrap_or_default()
.iter()
.filter_map(|repo| gunnar_core::RepoRef::new(&repo.namespace, &repo.name).ok())
.filter_map(|path| self.stores.repo_dir(&path).ok())
.map(|dir| crate::control_plane::repo_bytes(&dir).lfs)
.sum();
Some(gunnar_lfs::Allowance {
bytes: cap.saturating_sub(held),
refusal: format!(
"plan {} has {} bytes of lfs room left (cap {} bytes, {} already held): \
this upload does not fit; delete or buy more at {}",
fact.plan,
cap.saturating_sub(held),
cap,
held,
self.url_for(namespace)
),
})
}
}
pub const PACK_CEILING_SLACK_DIVISOR: u64 = 16;
pub const PACK_CEILING_SLACK_FLOOR_BYTES: u64 = 64 * 1024 * 1024;
struct PackQuota {
plan: String,
cap: u64,
held: u64,
}
impl EntitlementSlot {
fn pack_quota_state(&self, namespace: &str) -> Option<PackQuota> {
if let Ok(Some(clan)) = self.stores.auth().clan(namespace) {
let (pack, lfs) = self.namespace_bytes(namespace);
return Some(PackQuota {
plan: format!("clan {namespace}"),
cap: clan.room_in_force(&self.lapsed_members(&clan)),
held: pack.saturating_add(lfs),
});
}
let tenant = TenantId(namespace.to_owned());
let Some(fact) = self.cache.fact(&tenant) else {
return self.no_fact_quota(namespace);
};
let cap = fact
.caps
.get("pack_bytes")
.copied()
.filter(|cap| *cap > 0)?;
let cap = cap.saturating_sub(self.stores.auth().contributed_by(namespace).unwrap_or(0));
let held = self.namespace_pack_bytes(namespace);
Some(PackQuota {
plan: fact.plan.clone(),
cap,
held,
})
}
pub fn sells_room_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
Arc::clone(&self.sells_room)
}
fn no_fact_quota(&self, namespace: &str) -> Option<PackQuota> {
let sells = self.sells_room.load(std::sync::atomic::Ordering::Relaxed);
if !sells {
return None;
}
let auth = self.stores.auth();
let person = matches!(auth.principal(namespace), Ok(Some(p)) if p.kind == gunnar_core::PrincipalKind::User);
let seller = auth.holds_server_grant(namespace).unwrap_or(true);
gunnar_grpc::selling::no_room_yet(sells, false, person, seller).then(|| PackQuota {
plan: gunnar_grpc::selling::NO_ROOM_YET.to_string(),
cap: 0,
held: self.namespace_pack_bytes(namespace),
})
}
fn namespace_pack_bytes(&self, namespace: &str) -> u64 {
self.namespace_bytes(namespace).0
}
fn namespace_bytes(&self, namespace: &str) -> (u64, u64) {
self.stores
.catalog()
.list(Some(namespace))
.unwrap_or_default()
.iter()
.filter_map(|repo| gunnar_core::RepoRef::new(&repo.namespace, &repo.name).ok())
.filter_map(|path| self.stores.repo_dir(&path).ok())
.map(|dir| crate::control_plane::repo_bytes(&dir))
.fold((0u64, 0u64), |(pack, lfs), bytes| {
(
pack.saturating_add(bytes.pack),
lfs.saturating_add(bytes.lfs),
)
})
}
fn share_in_force(&self, member: &str, now: u64) -> bool {
let Some(fact) = self.cache.fact(&TenantId(member.to_owned())) else {
return true;
};
match fact.state {
State::Suspended | State::Retention => false,
_ => fact.paid_until_unix_ms.map_or(true, |until| until > now),
}
}
pub fn lapsed_members(&self, clan: &gunnar_core::Clan) -> Vec<String> {
let now = u64::try_from(now_unix_ms()).unwrap_or(0);
clan.members
.iter()
.filter(|m| !self.share_in_force(&m.principal, now))
.map(|m| m.principal.clone())
.collect()
}
}
fn state_from_proto(state: gunnar_proto::EntitlementState) -> Option<EntitlementState> {
use gunnar_proto::EntitlementState as P;
Some(match state {
P::Unspecified => return None,
P::Free => EntitlementState::Free,
P::Paid => EntitlementState::Paid,
P::Grace => EntitlementState::Grace,
P::Suspended => EntitlementState::Suspended,
P::Retention => EntitlementState::Retention,
})
}
pub(crate) fn state_to_proto(state: EntitlementState) -> gunnar_proto::EntitlementState {
use gunnar_proto::EntitlementState as P;
match state {
EntitlementState::Free => P::Free,
EntitlementState::Paid => P::Paid,
EntitlementState::Grace => P::Grace,
EntitlementState::Suspended => P::Suspended,
EntitlementState::Retention => P::Retention,
}
}
fn from_monetize_state(state: State) -> EntitlementState {
match state {
State::Free => EntitlementState::Free,
State::Paid => EntitlementState::Paid,
State::Grace => EntitlementState::Grace,
State::Suspended => EntitlementState::Suspended,
State::Retention => EntitlementState::Retention,
}
}
fn to_monetize_state(state: EntitlementState) -> State {
match state {
EntitlementState::Free => State::Free,
EntitlementState::Paid => State::Paid,
EntitlementState::Grace => State::Grace,
EntitlementState::Suspended => State::Suspended,
EntitlementState::Retention => State::Retention,
}
}
const MAX_ISSUED_AHEAD_MS: u64 = 60 * 60 * 1000;
fn same_fact(held: Option<&Entitlement>, offered: &Entitlement) -> bool {
held.is_some_and(|h| {
h.signature == offered.signature && h.issued_signature == offered.issued_signature
})
}
fn to_monetize(fact: &Entitlement) -> monetize_embed::EntitlementFact {
monetize_embed::EntitlementFact {
tenant: TenantId(fact.namespace.clone()),
plan: fact.plan.clone(),
state: to_monetize_state(fact.state),
paid_until_unix_ms: fact.paid_until_unix_ms,
caps: fact.caps.clone(),
source: fact.source.clone(),
signature: fact.signature.clone(),
issued_unix_ms: fact.issued_unix_ms,
issued_signature: fact.issued_signature.clone(),
}
}
fn monetize_to_proto(fact: &monetize_embed::EntitlementFact) -> ProtoFact {
ProtoFact {
namespace: fact.tenant.0.clone(),
plan: fact.plan.clone(),
state: state_to_proto(from_monetize_state(fact.state)) as i32,
paid_until_unix_ms: fact.paid_until_unix_ms,
caps: fact.caps.clone(),
source: fact.source.clone(),
signature: fact.signature.clone(),
issued_unix_ms: fact.issued_unix_ms,
issued_signature: fact.issued_signature.clone(),
}
}
fn hex32(k: &[u8; 32]) -> String {
k.iter().map(|b| format!("{b:02x}")).collect()
}