v0.1.0:24 — Keysat licensing service end-to-end

Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages,
discount codes, free-license redemption, Apply-discount UX,
self-licensing, and v0.1.0 release notes.
This commit is contained in:
Grant
2026-05-07 10:33:39 -05:00
parent 432250bffc
commit 6ac118ae70
90 changed files with 14896 additions and 524 deletions
+189
View File
@@ -0,0 +1,189 @@
//! Entry point. Wires config → logging → DB → keypair → HTTP server.
mod api;
mod btcpay;
mod config;
mod crypto;
mod db;
mod error;
mod license_self;
mod models;
mod payment;
mod rate_limit;
mod reconcile;
mod tipping;
mod webhooks;
/// Hex-encoded SHA-256 of a string — used everywhere we need a deterministic
/// id from a raw value (machine fingerprints, admin key hashes).
pub fn hex_sha256(s: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(s.as_bytes());
hex::encode(hasher.finalize())
}
use anyhow::Context;
use std::sync::Arc;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// --- logging ---
tracing_subscriber::registry()
.with(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,hyper=warn")),
)
.with(fmt::layer().with_target(false))
.init();
// --- config ---
let cfg = config::Config::from_env().context("loading configuration")?;
tracing::info!(
bind = %cfg.bind,
db = %cfg.db_path.display(),
btcpay_url = %cfg.btcpay_url,
btcpay_browser_url = ?cfg.btcpay_browser_url,
btcpay_public_url = ?cfg.btcpay_public_url,
"starting keysat v{}",
env!("CARGO_PKG_VERSION")
);
// --- self-license tier (Keysat-licenses-Keysat) ---
// Verifies any /data/keysat-license.txt against the embedded master
// pubkey. In permissive builds (default) a missing/invalid license
// logs a warning and we continue. In enforce builds (compiled with
// KEYSAT_LICENSE_ENFORCE=1) a missing/invalid license refuses to
// start. Result is held in app state so the admin UI can surface it.
let self_tier = Arc::new(tokio::sync::RwLock::new(
license_self::check_at_boot()
.context("Keysat self-license check failed (enforce mode)")?,
));
// --- database ---
let pool = db::init(&cfg.db_path).await?;
// --- signing key ---
let keypair = crypto::keys::load_or_generate(&pool).await?;
tracing::info!(
"signing key ready; public key:\n{}",
keypair.public_key_pem.trim()
);
// --- payment provider (may be None until operator connects) ---
let provider: Option<Arc<dyn payment::PaymentProvider>> =
load_btcpay_provider(&pool, &cfg).await.map(|p| {
let arc: Arc<dyn payment::PaymentProvider> = Arc::new(p);
arc
});
match &provider {
Some(p) => tracing::info!(provider = p.kind().as_str(), "payment provider connected"),
None => tracing::warn!(
"no payment provider yet configured — purchases will return 503 until the \
operator completes the 'Connect BTCPay' flow"
),
}
let state = api::AppState {
db: pool,
keypair: Arc::new(keypair),
payment: Arc::new(tokio::sync::RwLock::new(provider)),
config: Arc::new(cfg.clone()),
self_tier,
};
// Spawn background loops before handing state to the router.
reconcile::spawn(state.clone());
webhooks::spawn_delivery_worker(state.clone());
let app = api::router(state).layer(TraceLayer::new_for_http());
// --- serve ---
let listener = tokio::net::TcpListener::bind(cfg.bind)
.await
.with_context(|| format!("binding to {}", cfg.bind))?;
tracing::info!("listening on http://{}", cfg.bind);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
tracing::info!("shutdown complete");
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received");
}
/// Load a BtcpayProvider from (in order): DB, then env var seed, then None.
/// Never fails — an unconfigured service simply returns 503 on purchase paths
/// until the operator completes the connect flow. Returns the concrete
/// `BtcpayProvider` so the caller can decide how to wrap it (we wrap as
/// `Arc<dyn PaymentProvider>` in `main`).
async fn load_btcpay_provider(
pool: &sqlx::SqlitePool,
cfg: &config::Config,
) -> Option<payment::btcpay::BtcpayProvider> {
// DB first.
if let Ok(Some(saved)) = btcpay::config::load(pool).await {
let client = btcpay::client::BtcpayClient::new(
&saved.base_url,
&saved.api_key,
&saved.store_id,
);
return Some(
payment::btcpay::BtcpayProvider::new(client, saved.webhook_secret)
.with_public_base(cfg.btcpay_public_url.clone()),
);
}
// Fall back to env seed (useful for dev / legacy installs).
if let (Some(api_key), Some(store_id), Some(secret)) = (
cfg.btcpay_api_key.as_deref(),
cfg.btcpay_store_id.as_deref(),
cfg.btcpay_webhook_secret.as_deref(),
) {
let client =
btcpay::client::BtcpayClient::new(&cfg.btcpay_url, api_key, store_id);
// Persist the seed into DB so it survives env changes.
let _ = btcpay::config::save(
pool,
&btcpay::config::BtcpayConfig {
base_url: cfg.btcpay_url.clone(),
api_key: api_key.to_string(),
store_id: store_id.to_string(),
webhook_id: None,
webhook_secret: secret.to_string(),
},
)
.await;
return Some(
payment::btcpay::BtcpayProvider::new(client, secret.to_string())
.with_public_base(cfg.btcpay_public_url.clone()),
);
}
None
}