257669092b
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.
337 lines
11 KiB
Rust
337 lines
11 KiB
Rust
//! Machines — individual install records bound to a license.
|
|
//!
|
|
//! In the single-seat case (`licenses.max_machines = 1`), the first
|
|
//! successful `/v1/validate` call locks the fingerprint onto the license
|
|
//! and creates a `machines` row. Later validations keep heartbeating that
|
|
//! row.
|
|
//!
|
|
//! In the multi-seat case (`max_machines > 1` or `0` for unlimited),
|
|
//! validate auto-activates up to the cap. Beyond the cap, the client gets a
|
|
//! `too_many_machines` reject and is expected to call
|
|
//! `POST /v1/machines/deactivate` with the fingerprint of an old install to
|
|
//! free up a slot, then retry.
|
|
//!
|
|
//! Explicit activation endpoints (`POST /v1/machines/activate`) are offered
|
|
//! for apps that want to prompt the user about seat usage before starting up
|
|
//! for the first time. They behave identically to `/v1/validate`'s implicit
|
|
//! activation, just without requiring the full key check.
|
|
//!
|
|
//! Admin endpoints let operators look at who's using what and force-kick a
|
|
//! machine off a license.
|
|
|
|
use crate::api::admin::{request_context, require_admin};
|
|
use crate::api::AppState;
|
|
use crate::crypto;
|
|
use crate::db::repo;
|
|
use crate::error::{AppError, AppResult};
|
|
use axum::{
|
|
extract::{Path, Query, State},
|
|
http::HeaderMap,
|
|
Json,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
|
|
// ---------- Public endpoints (client-facing) ----------
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ActivateReq {
|
|
pub key: String,
|
|
pub fingerprint: String,
|
|
pub hostname: Option<String>,
|
|
pub platform: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ActivateResp {
|
|
pub ok: bool,
|
|
pub machine_id: Option<String>,
|
|
pub active_count: i64,
|
|
pub max_machines: i64,
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
pub async fn activate(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<ActivateReq>,
|
|
) -> AppResult<Json<ActivateResp>> {
|
|
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
|
|
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
|
|
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
|
|
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
|
|
let license_id = payload.license_id.to_string();
|
|
let license = repo::get_license_by_id(&state.db, &license_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound(format!("license {license_id}")))?;
|
|
|
|
if license.status != "active" {
|
|
return Ok(Json(ActivateResp {
|
|
ok: false,
|
|
machine_id: None,
|
|
active_count: 0,
|
|
max_machines: license.max_machines,
|
|
reason: Some(license.status),
|
|
}));
|
|
}
|
|
|
|
let client_ip = headers
|
|
.get("x-forwarded-for")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
|
|
|
|
let fp_hash = crate::hex_sha256(&req.fingerprint);
|
|
|
|
if let Some(m) =
|
|
repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?
|
|
{
|
|
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
|
|
let active = repo::list_active_machines(&state.db, &license_id).await?;
|
|
return Ok(Json(ActivateResp {
|
|
ok: true,
|
|
machine_id: Some(m.id),
|
|
active_count: active.len() as i64,
|
|
max_machines: license.max_machines,
|
|
reason: None,
|
|
}));
|
|
}
|
|
|
|
let active = repo::list_active_machines(&state.db, &license_id).await?;
|
|
if license.max_machines > 0 && active.len() as i64 >= license.max_machines {
|
|
return Ok(Json(ActivateResp {
|
|
ok: false,
|
|
machine_id: None,
|
|
active_count: active.len() as i64,
|
|
max_machines: license.max_machines,
|
|
reason: Some("too_many_machines".into()),
|
|
}));
|
|
}
|
|
|
|
let m = repo::activate_machine(
|
|
&state.db,
|
|
&license_id,
|
|
&req.fingerprint,
|
|
&fp_hash,
|
|
req.hostname.as_deref(),
|
|
req.platform.as_deref(),
|
|
client_ip.as_deref(),
|
|
)
|
|
.await?;
|
|
crate::webhooks::dispatch(
|
|
&state,
|
|
"machine.activated",
|
|
&json!({
|
|
"license_id": license_id,
|
|
"machine_id": m.id,
|
|
"fingerprint_hash": fp_hash,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let active = repo::list_active_machines(&state.db, &license_id).await?;
|
|
Ok(Json(ActivateResp {
|
|
ok: true,
|
|
machine_id: Some(m.id),
|
|
active_count: active.len() as i64,
|
|
max_machines: license.max_machines,
|
|
reason: None,
|
|
}))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct HeartbeatReq {
|
|
pub key: String,
|
|
pub fingerprint: String,
|
|
}
|
|
|
|
pub async fn heartbeat(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<HeartbeatReq>,
|
|
) -> AppResult<Json<Value>> {
|
|
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
|
|
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
|
|
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
|
|
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
|
|
let license_id = payload.license_id.to_string();
|
|
|
|
// Rate-limit heartbeats per-license to 60/hr.
|
|
if !crate::rate_limit::consume(
|
|
&state.db,
|
|
"heartbeat_license",
|
|
&license_id,
|
|
/* capacity */ 60.0,
|
|
/* refill_per_second */ 60.0 / 3600.0,
|
|
)
|
|
.await?
|
|
{
|
|
return Ok(Json(json!({ "ok": false, "reason": "rate_limited" })));
|
|
}
|
|
|
|
let fp_hash = crate::hex_sha256(&req.fingerprint);
|
|
let client_ip = headers
|
|
.get("x-forwarded-for")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
|
|
|
|
match repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await? {
|
|
Some(m) => {
|
|
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
|
|
Ok(Json(json!({ "ok": true, "machine_id": m.id })))
|
|
}
|
|
None => Ok(Json(json!({ "ok": false, "reason": "not_activated" }))),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct DeactivateReq {
|
|
pub key: String,
|
|
pub fingerprint: String,
|
|
#[serde(default)]
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
pub async fn deactivate(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<DeactivateReq>,
|
|
) -> AppResult<Json<Value>> {
|
|
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
|
|
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
|
|
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
|
|
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
|
|
let license_id = payload.license_id.to_string();
|
|
let fp_hash = crate::hex_sha256(&req.fingerprint);
|
|
|
|
let m = repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?;
|
|
let Some(m) = m else {
|
|
return Ok(Json(json!({ "ok": false, "reason": "not_found" })));
|
|
};
|
|
let reason = req
|
|
.reason
|
|
.unwrap_or_else(|| "client_requested".to_string());
|
|
repo::deactivate_machine(&state.db, &m.id, &reason).await?;
|
|
crate::webhooks::dispatch(
|
|
&state,
|
|
"machine.deactivated",
|
|
&json!({
|
|
"license_id": license_id,
|
|
"machine_id": m.id,
|
|
"reason": reason,
|
|
}),
|
|
)
|
|
.await;
|
|
// Single-seat legacy: also clear licenses.fingerprint so the next client
|
|
// can re-bind cleanly.
|
|
let license = repo::get_license_by_id(&state.db, &license_id).await?;
|
|
if let Some(lic) = license {
|
|
if lic.max_machines == 1 {
|
|
let _ = sqlx::query("UPDATE licenses SET fingerprint = NULL WHERE id = ?")
|
|
.bind(&license_id)
|
|
.execute(&state.db)
|
|
.await;
|
|
}
|
|
}
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
// ---------- Admin endpoints ----------
|
|
|
|
/// Query for the admin Machines list. All filters are optional and
|
|
/// conjunctive — leaving them all blank returns every machine across
|
|
/// every license, default-sorted by most-recent heartbeat. The admin UI
|
|
/// Machines tab uses this default-no-filter form to render a global
|
|
/// view; the Licenses-tab drill-down sets `license_id`.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct AdminListQuery {
|
|
#[serde(default)]
|
|
pub license_id: Option<String>,
|
|
#[serde(default)]
|
|
pub product_id: Option<String>,
|
|
#[serde(default)]
|
|
pub product_slug: Option<String>,
|
|
#[serde(default)]
|
|
pub include_inactive: bool,
|
|
/// Cap on result size; defaults to 500. Admin UI paginates client-side.
|
|
#[serde(default)]
|
|
pub limit: Option<i64>,
|
|
}
|
|
|
|
pub async fn admin_list(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Query(q): Query<AdminListQuery>,
|
|
) -> AppResult<Json<Value>> {
|
|
require_admin(&state, &headers)?;
|
|
|
|
// Resolve product_slug → product_id if the caller passed the slug
|
|
// form. Either works; product_id takes precedence on conflict.
|
|
let resolved_product_id: Option<String> = if let Some(pid) = q.product_id.as_deref() {
|
|
Some(pid.to_string())
|
|
} else if let Some(slug) = q.product_slug.as_deref() {
|
|
match repo::get_product_by_slug(&state.db, slug).await? {
|
|
Some(p) => Some(p.id),
|
|
None => return Err(AppError::NotFound(format!("product '{slug}'"))),
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let machines = repo::list_machines_admin(
|
|
&state.db,
|
|
resolved_product_id.as_deref(),
|
|
q.license_id.as_deref(),
|
|
q.include_inactive,
|
|
q.limit.unwrap_or(500).clamp(1, 5000),
|
|
)
|
|
.await?;
|
|
Ok(Json(json!({ "machines": machines })))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct AdminDeactivateReq {
|
|
#[serde(default)]
|
|
pub reason: String,
|
|
}
|
|
|
|
pub async fn admin_deactivate(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<AdminDeactivateReq>,
|
|
) -> AppResult<Json<Value>> {
|
|
let actor_hash = require_admin(&state, &headers)?;
|
|
let (ip, ua) = request_context(&headers);
|
|
let reason = if req.reason.is_empty() {
|
|
"admin deactivate".to_string()
|
|
} else {
|
|
req.reason
|
|
};
|
|
let m = repo::get_machine_by_id(&state.db, &id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound(format!("machine {id}")))?;
|
|
repo::deactivate_machine(&state.db, &id, &reason).await?;
|
|
let _ = repo::insert_audit(
|
|
&state.db,
|
|
"admin_api_key",
|
|
Some(&actor_hash),
|
|
"machine.deactivate",
|
|
Some("machine"),
|
|
Some(&id),
|
|
ip.as_deref(),
|
|
ua.as_deref(),
|
|
&json!({ "license_id": m.license_id, "reason": reason }),
|
|
)
|
|
.await;
|
|
crate::webhooks::dispatch(
|
|
&state,
|
|
"machine.deactivated",
|
|
&json!({
|
|
"license_id": m.license_id,
|
|
"machine_id": id,
|
|
"reason": reason,
|
|
}),
|
|
)
|
|
.await;
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|