v0.2.0:11 + v0.2.0:12 — Archive, Settings, agent surface, machines redesign
Two release cycles prepared together: v0.2.0:11 (policy archive + safe- delete cleanup + brand-consistent confirm modals) and v0.2.0:12 (Settings tab + agent-friendly operator API + machines tab redesign + buyer-facing copy alignment). Highlights: - Migration 0015: policies.archived_at column. Archive button on tier cards; safe-delete relaxed to ignore revoked-license tombstones; renewal worker refuses archived policies. - Migration 0016: scoped_api_keys table. Four roles (read-only, license-issuer, support, full-admin) with bounded scopes. Master admin_api_key still works on every endpoint; scoped keys gated on endpoints wired through require_scope(). - New /v1/openapi.json — public, no auth. Curated OpenAPI 3.1 spec for agent / SDK discovery. - New Settings tab: Operator name + Payment providers panel + API keys management. Replaces 8 StartOS Actions (Zaprite all, BTCPay all, operator name, switch-provider). StartOS Actions pruned to 4 install-time essentials. - Machines tab rewritten: global default view grouped by product, filter pills with counts, quick-stats row, drill-down via new "Machines" button on each Licenses-tab row. New repo helper list_machines_admin joins machines x licenses x products server-side. - Branded confirmModal replaces every native window.confirm() call in the admin UI (7 callsites). - Enforce mode killed: KEYSAT_LICENSE_ENFORCE compile-time flag retired; daemon always boots; missing self-license -> Creator (free) tier. "Unlicensed" label gone from admin UI. - Zaprite gated on the new zaprite_payments entitlement (renamed from card_payments to reflect the broader gateway). - Creator code cap 5 -> 10. - KEYSAT_AGENT_GUIDE.md: auth, role-to-scope mapping, error envelope, webhook events, worked recipes. - Buyer-facing copy aligned with new positioning: "Bitcoin-native self-hosted software licensing" everywhere on production surfaces. - Cross-product safety section (Section 9a) added to KEYSAT_INTEGRATION.md. - 5 new API integration smoke tests covering OpenAPI, scoped API keys CRUD, role-elevation guard, and Zaprite-tier gating. Test count: 83 passing (was 78). All migration tests pass against 0015 and 0016 applied to populated DBs.
This commit is contained in:
@@ -7,22 +7,20 @@
|
||||
//! customer licenses, and verify its signature against the master
|
||||
//! public key.
|
||||
//!
|
||||
//! Two modes:
|
||||
//! - `Permissive` (default for dev builds): missing or invalid
|
||||
//! licenses log a warning and the daemon starts in
|
||||
//! `Tier::Unlicensed`. No features are gated yet — that's a
|
||||
//! future v0.2.x flip.
|
||||
//! - `Enforce`: missing or invalid licenses cause the daemon to
|
||||
//! refuse to start. Set at compile time via the
|
||||
//! `KEYSAT_LICENSE_ENFORCE=1` env var. Marketplace builds set
|
||||
//! this; local dev builds don't.
|
||||
//! Missing or invalid self-licenses log a warning and the daemon starts in
|
||||
//! `Tier::Unlicensed`, which the admin UI labels "Creator" — the free tier
|
||||
//! with the Creator caps applied (5 products, 5 policies per product, 10
|
||||
//! active codes). The daemon is always functional out of the box; paying
|
||||
//! lifts the caps and unlocks `recurring_billing` + `zaprite_payments`.
|
||||
//!
|
||||
//! The master pubkey is the *public* half of an Ed25519 keypair held
|
||||
//! offline by the keysat.xyz team. It is not secret — embedding it in
|
||||
//! source on GitHub is fine. Anyone with the *private* half can mint
|
||||
//! Keysat self-licenses; the private half lives on paper backup +
|
||||
//! hardware-token storage and never touches a connected machine
|
||||
//! except briefly when a master Keysat instance is being initialized.
|
||||
//! The master pubkey is the *public* half of an Ed25519 keypair held by
|
||||
//! the operator who issues Keysat-product licenses. It is not secret —
|
||||
//! embedding it in source on GitHub is fine. Anyone with the *private*
|
||||
//! half can mint Keysat self-licenses. On the master Keysat instance
|
||||
//! that owner runs, the private half doubles as the per-instance
|
||||
//! license-signing key (stored in the `server_keys` table); on every
|
||||
//! other Keysat install the private half doesn't exist and the daemon
|
||||
//! only ever verifies, never signs.
|
||||
|
||||
use crate::crypto::{parse_key, verify_payload};
|
||||
use anyhow::{bail, Context, Result};
|
||||
@@ -45,28 +43,12 @@ MCowBQYDK2VwAyEAgsromMy4osMJplX1rY0fd4ouS6wfkm/vfeY2gXEQHkA=
|
||||
/// persistent data volume so it survives package upgrades.
|
||||
pub const SELF_LICENSE_PATH: &str = "/data/keysat-license.txt";
|
||||
|
||||
/// Build-time enforcement toggle. `KEYSAT_LICENSE_ENFORCE=1` at
|
||||
/// `cargo build` time enables enforce mode.
|
||||
const ENFORCE_FLAG: Option<&str> = option_env!("KEYSAT_LICENSE_ENFORCE");
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// Missing/invalid license logs a warning and continues. Default.
|
||||
Permissive,
|
||||
/// Missing/invalid license refuses to start the daemon.
|
||||
Enforce,
|
||||
}
|
||||
|
||||
pub fn mode() -> Mode {
|
||||
match ENFORCE_FLAG {
|
||||
Some("1") | Some("true") | Some("yes") => Mode::Enforce,
|
||||
_ => Mode::Permissive,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Tier {
|
||||
/// No license configured, or license verify failed in permissive mode.
|
||||
/// No self-license file, or verify failed. Surfaces as "Creator"
|
||||
/// in the admin UI — the free tier with the Creator caps applied.
|
||||
/// `reason` is for logs and the admin `/v1/admin/tier` payload, not
|
||||
/// shown to end users.
|
||||
Unlicensed { reason: String },
|
||||
/// Valid license verified against the trust-root.
|
||||
Licensed {
|
||||
@@ -79,34 +61,30 @@ pub enum Tier {
|
||||
}
|
||||
|
||||
impl Tier {
|
||||
/// String form for log / metrics labels. `Unlicensed` surfaces as
|
||||
/// "creator" since that's how the admin UI presents it — operators
|
||||
/// see one consistent name across logs and dashboard.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Tier::Unlicensed { .. } => "unlicensed",
|
||||
Tier::Unlicensed { .. } => "creator",
|
||||
Tier::Licensed { .. } => "licensed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot-time check. In permissive mode this always returns `Ok`; in
|
||||
/// enforce mode it returns `Err` on missing / invalid / expired
|
||||
/// licenses, which causes `main` to bail out before we open any
|
||||
/// network sockets.
|
||||
/// Boot-time check. Always returns `Ok` — Keysat boots into the Creator
|
||||
/// (free) tier when no valid self-license is present, never refuses to
|
||||
/// start. Logs a one-line info or warn line for operator visibility.
|
||||
pub fn check_at_boot() -> Result<Tier> {
|
||||
let mode = mode();
|
||||
tracing::info!(
|
||||
mode = mode.as_str(),
|
||||
"Keysat self-license check (mode={})",
|
||||
mode.as_str()
|
||||
);
|
||||
|
||||
let license_str = match read_license_string() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let reason = format!(
|
||||
"no license at {} or KEYSAT_LICENSE env var",
|
||||
"no license at {} or KEYSAT_LICENSE env var; running Creator (free) tier",
|
||||
SELF_LICENSE_PATH
|
||||
);
|
||||
return handle_missing_or_invalid(mode, reason, None);
|
||||
tracing::info!(tier = "creator", "Keysat self-license: {}", reason);
|
||||
return Ok(Tier::Unlicensed { reason });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,37 +94,12 @@ pub fn check_at_boot() -> Result<Tier> {
|
||||
Ok(tier)
|
||||
}
|
||||
Err(e) => {
|
||||
let reason = format!("verification failed: {e:#}");
|
||||
handle_missing_or_invalid(mode, reason, Some(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_missing_or_invalid(
|
||||
mode: Mode,
|
||||
reason: String,
|
||||
err: Option<anyhow::Error>,
|
||||
) -> Result<Tier> {
|
||||
match mode {
|
||||
Mode::Permissive => {
|
||||
tracing::warn!(
|
||||
tier = "unlicensed",
|
||||
"Keysat self-license: {} — running unlicensed (permissive build)",
|
||||
reason
|
||||
let reason = format!(
|
||||
"verification failed: {e:#} — falling back to Creator (free) tier"
|
||||
);
|
||||
tracing::warn!(tier = "creator", "Keysat self-license: {}", reason);
|
||||
Ok(Tier::Unlicensed { reason })
|
||||
}
|
||||
Mode::Enforce => {
|
||||
tracing::error!(
|
||||
"Keysat self-license: {} — refusing to start. \
|
||||
Activate via StartOS → Keysat → Actions → Activate Keysat license.",
|
||||
reason
|
||||
);
|
||||
match err {
|
||||
Some(e) => Err(e.context("self-license invalid (enforce mode)")),
|
||||
None => bail!("self-license missing (enforce mode): {reason}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,15 +199,6 @@ fn log_licensed(tier: &Tier) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Permissive => "permissive",
|
||||
Mode::Enforce => "enforce",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live-refresh the daemon's self-tier from the local `licenses` row.
|
||||
///
|
||||
/// Why this exists: `check_at_boot` parses the on-disk LIC1 key and
|
||||
|
||||
Reference in New Issue
Block a user