Files
keysat/licensing-service/src/config.rs
T
Grant 6ac118ae70 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.
2026-05-07 10:33:39 -05:00

140 lines
5.8 KiB
Rust

//! Runtime configuration.
//!
//! Loaded once at startup from environment variables. A `.env` file is read
//! if present (via `dotenvy`) so local development is frictionless. In
//! production on StartOS, the same variables are set by the service manifest.
use anyhow::{anyhow, Context, Result};
use std::net::SocketAddr;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct Config {
/// Where the HTTP server binds.
pub bind: SocketAddr,
/// Path to the SQLite database file (e.g. `/data/keysat.db` inside a
/// Start9 container; `./data/keysat.db` in dev).
pub db_path: PathBuf,
/// Shared secret required on admin endpoints via `Authorization: Bearer ...`.
/// Generated once by the operator and kept secret.
pub admin_api_key: String,
/// BTCPay Server base URL used for daemon → BTCPay API calls. On
/// StartOS this is the internal-network hostname like
/// `http://btcpayserver.startos:23000`, which is only resolvable from
/// inside other StartOS containers.
pub btcpay_url: String,
/// BTCPay Server base URL used for the OPERATOR'S BROWSER. The
/// authorize flow redirects the operator's browser to BTCPay's
/// consent page; that target must be reachable from the LAN /
/// clearnet, not the internal-network hostname. The wrapper sets
/// this to BTCPay's preferred operator-facing URL — typically
/// mDNS (`https://immense-voyage.local:49347`) since the operator
/// is on the same LAN as the Start9.
pub btcpay_browser_url: Option<String>,
/// BTCPay Server PUBLIC URL used for BUYER-facing redirects.
/// The daemon rewrites checkout URLs returned by BTCPay's API so
/// they point at this URL — random internet buyers can't reach
/// mDNS or LAN URLs, so this needs to be a real clearnet domain
/// like `https://btcpay.your-domain.com`. Falls back to
/// `btcpay_browser_url` if unset (useful for local testing only).
pub btcpay_public_url: Option<String>,
/// Seed BTCPay API key, used only on first boot before the operator has
/// completed the authorize flow. Leave empty in the normal case.
pub btcpay_api_key: Option<String>,
/// Seed BTCPay store id. Same rules as `btcpay_api_key` — empty in the
/// normal case.
pub btcpay_store_id: Option<String>,
/// Seed webhook secret. Only used when bootstrapping from env vars.
pub btcpay_webhook_secret: Option<String>,
/// Public base URL of *this* Keysat instance, used when constructing
/// invoice redirect / webhook URLs (e.g. `https://license.example.com`).
pub public_base_url: String,
/// Optional human-readable operator name shown in `/` index responses.
pub operator_name: Option<String>,
}
impl Config {
pub fn from_env() -> Result<Self> {
// Best-effort load of .env in dev. Missing file is not an error.
let _ = dotenvy::dotenv();
// All runtime knobs live under `KEYSAT_*`. For older installs and
// dev shells that predate the rename we still honour the original
// `LICENSING_*` names as a silent fallback.
let bind_str = env_with_fallback("KEYSAT_BIND", "LICENSING_BIND")
.unwrap_or_else(|| "0.0.0.0:8080".to_string());
let bind: SocketAddr = bind_str
.parse()
.with_context(|| format!("KEYSAT_BIND is not a valid socket address: {bind_str}"))?;
let db_path = PathBuf::from(
env_with_fallback("KEYSAT_DB_PATH", "LICENSING_DB_PATH")
.unwrap_or_else(|| "./data/keysat.db".into()),
);
let admin_api_key = required_with_fallback("KEYSAT_ADMIN_API_KEY", "LICENSING_ADMIN_API_KEY")?;
if admin_api_key.len() < 32 {
return Err(anyhow!(
"KEYSAT_ADMIN_API_KEY must be at least 32 characters (use `openssl rand -hex 32`)"
));
}
let btcpay_url = required("BTCPAY_URL")?;
let btcpay_browser_url = optional_nonempty("BTCPAY_BROWSER_URL")
.map(|s| s.trim_end_matches('/').to_string());
let btcpay_public_url = optional_nonempty("BTCPAY_PUBLIC_URL")
.map(|s| s.trim_end_matches('/').to_string())
// Fallback: if no public URL is plumbed, use browser URL.
// Won't work for real customers but is fine for local testing.
.or_else(|| btcpay_browser_url.clone());
let btcpay_api_key = optional_nonempty("BTCPAY_API_KEY");
let btcpay_store_id = optional_nonempty("BTCPAY_STORE_ID");
let btcpay_webhook_secret = optional_nonempty("BTCPAY_WEBHOOK_SECRET");
let public_base_url = required_with_fallback("KEYSAT_PUBLIC_URL", "LICENSING_PUBLIC_URL")?;
let operator_name = env_with_fallback("KEYSAT_OPERATOR_NAME", "LICENSING_OPERATOR_NAME");
Ok(Self {
bind,
db_path,
admin_api_key,
btcpay_url: btcpay_url.trim_end_matches('/').to_string(),
btcpay_browser_url,
btcpay_public_url,
btcpay_api_key,
btcpay_store_id,
btcpay_webhook_secret,
public_base_url: public_base_url.trim_end_matches('/').to_string(),
operator_name,
})
}
}
fn optional_nonempty(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.is_empty())
}
fn required(name: &str) -> Result<String> {
std::env::var(name).map_err(|_| anyhow!("missing required env var: {name}"))
}
/// Look up a var under its current (KEYSAT_*) name, falling back to the
/// pre-rename (LICENSING_*) name if unset.
fn env_with_fallback(primary: &str, fallback: &str) -> Option<String> {
optional_nonempty(primary).or_else(|| optional_nonempty(fallback))
}
fn required_with_fallback(primary: &str, fallback: &str) -> Result<String> {
env_with_fallback(primary, fallback)
.ok_or_else(|| anyhow!("missing required env var: {primary} (or {fallback})"))
}