feat: full Rust rewrite — axum, sqlx, RSA-4096 license signing, argon2, MariaDB
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
RUST_LOG=nixpanel_licensing=info,tower_http=info
|
||||
|
||||
# Database — MariaDB/MySQL connection string
|
||||
DATABASE_URL=mysql://nixpanel_license:[email protected]:3306/nixpanel_licensing
|
||||
|
||||
# JWT (admin sessions) — generate: openssl rand -hex 64
|
||||
JWT_SECRET=changeme_use_openssl_rand_hex_64
|
||||
JWT_EXPIRES_IN_SECS=86400
|
||||
|
||||
# RSA License Signing Keys — generate: cargo run --bin keygen
|
||||
LICENSE_PRIVATE_KEY_PATH=./keys/private.pem
|
||||
LICENSE_PUBLIC_KEY_PATH=./keys/public.pem
|
||||
LICENSE_TOKEN_TTL_DAYS=30
|
||||
LICENSE_GRACE_PERIOD_DAYS=7
|
||||
@@ -0,0 +1,5 @@
|
||||
target/
|
||||
.env
|
||||
keys/
|
||||
*.pem
|
||||
*.log
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
[package]
|
||||
name = "nixpanel-licensing"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "nixpanel-licensing"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "keygen"
|
||||
path = "src/bin/keygen.rs"
|
||||
|
||||
[dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Web framework
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
tower = { version = "0.4" }
|
||||
tower-http = { version = "0.5", features = ["cors", "trace", "limit"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "mysql", "uuid", "chrono", "migrate"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Auth / crypto
|
||||
jsonwebtoken = "9"
|
||||
argon2 = "0.5"
|
||||
rsa = { version = "0.9", features = ["sha2", "pkcs8"] }
|
||||
pkcs8 = { version = "0.10", features = ["pem", "std"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
rand = "0.8"
|
||||
|
||||
# IDs / time
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Config / env
|
||||
dotenvy = "0.15"
|
||||
|
||||
# Error handling
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
|
||||
# Logging
|
||||
tracing = "1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
@@ -0,0 +1,73 @@
|
||||
CREATE TABLE IF NOT EXISTS plans (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
max_domains INT NOT NULL DEFAULT 1,
|
||||
max_accounts INT NOT NULL DEFAULT 1,
|
||||
max_emails INT NOT NULL DEFAULT 10,
|
||||
max_databases INT NOT NULL DEFAULT 5,
|
||||
max_disk_gb INT NOT NULL DEFAULT 10,
|
||||
price_monthly DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS licenses (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
plan_id VARCHAR(36) NOT NULL,
|
||||
owner_email VARCHAR(255) NOT NULL,
|
||||
owner_name VARCHAR(255) NOT NULL,
|
||||
server_ip VARCHAR(45) NULL,
|
||||
server_hostname VARCHAR(255) NULL,
|
||||
server_fingerprint VARCHAR(512) NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
issued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NULL,
|
||||
last_verified DATETIME NULL,
|
||||
notes TEXT NULL,
|
||||
FOREIGN KEY (plan_id) REFERENCES plans(id) ON UPDATE CASCADE,
|
||||
INDEX idx_owner_email (owner_email),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_server_ip (server_ip)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS license_tokens (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
license_id VARCHAR(36) NOT NULL,
|
||||
token_hash VARCHAR(512) NOT NULL,
|
||||
issued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
revoked TINYINT(1) NOT NULL DEFAULT 0,
|
||||
revoked_at DATETIME NULL,
|
||||
FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
INDEX idx_license_id (license_id),
|
||||
INDEX idx_token_hash (token_hash)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS validation_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
license_id VARCHAR(36) NOT NULL,
|
||||
server_ip VARCHAR(45) NULL,
|
||||
server_fingerprint VARCHAR(512) NULL,
|
||||
result VARCHAR(30) NOT NULL,
|
||||
checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_license_id (license_id),
|
||||
INDEX idx_checked_at (checked_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'admin',
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
last_login DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT IGNORE INTO plans (id, name, max_domains, max_accounts, max_emails, max_databases, max_disk_gb, price_monthly) VALUES
|
||||
('00000000-0000-0000-0000-000000000001', 'Free', 1, 1, 5, 2, 5, 0.00),
|
||||
('00000000-0000-0000-0000-000000000002', 'Solo', 5, 5, 50, 10, 50, 9.99),
|
||||
('00000000-0000-0000-0000-000000000003', 'Host', 50, 50, 500, 100, 250, 29.99),
|
||||
('00000000-0000-0000-0000-000000000004', 'Agency', -1, -1, -1, -1, -1, 79.99);
|
||||
@@ -0,0 +1,32 @@
|
||||
/// Generates an RSA-4096 key pair for license token signing.
|
||||
/// Run with: cargo run --bin keygen
|
||||
use rsa::{pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}, RsaPrivateKey};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
fn main() {
|
||||
println!("Generating RSA-4096 key pair...");
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
let private_key = RsaPrivateKey::new(&mut rng, 4096)
|
||||
.expect("Failed to generate RSA key");
|
||||
let public_key = private_key.to_public_key();
|
||||
|
||||
let keys_dir = Path::new("keys");
|
||||
fs::create_dir_all(keys_dir).expect("Failed to create keys/ directory");
|
||||
|
||||
let private_pem = private_key
|
||||
.to_pkcs8_pem(LineEnding::LF)
|
||||
.expect("Failed to encode private key");
|
||||
let public_pem = public_key
|
||||
.to_public_key_pem(LineEnding::LF)
|
||||
.expect("Failed to encode public key");
|
||||
|
||||
fs::write(keys_dir.join("private.pem"), private_pem.as_bytes())
|
||||
.expect("Failed to write private.pem");
|
||||
fs::write(keys_dir.join("public.pem"), public_pem.as_bytes())
|
||||
.expect("Failed to write public.pem");
|
||||
|
||||
println!("Done:");
|
||||
println!(" keys/private.pem — keep secret, never commit");
|
||||
println!(" keys/public.pem — distribute to NixPanel deployments for offline verification");
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::env;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub database_url: String,
|
||||
pub jwt_secret: String,
|
||||
pub jwt_expires_in_secs: u64,
|
||||
pub license_private_key_path: String,
|
||||
pub license_public_key_path: String,
|
||||
pub license_token_ttl_days: u64,
|
||||
pub license_grace_period_days: u64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
Ok(Self {
|
||||
host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()),
|
||||
port: env::var("PORT")
|
||||
.unwrap_or_else(|_| "3000".into())
|
||||
.parse()
|
||||
.context("PORT must be a valid number")?,
|
||||
database_url: env::var("DATABASE_URL")
|
||||
.context("DATABASE_URL is required")?,
|
||||
jwt_secret: env::var("JWT_SECRET")
|
||||
.context("JWT_SECRET is required")?,
|
||||
jwt_expires_in_secs: env::var("JWT_EXPIRES_IN_SECS")
|
||||
.unwrap_or_else(|_| "86400".into())
|
||||
.parse()
|
||||
.context("JWT_EXPIRES_IN_SECS must be a number")?,
|
||||
license_private_key_path: env::var("LICENSE_PRIVATE_KEY_PATH")
|
||||
.unwrap_or_else(|_| "./keys/private.pem".into()),
|
||||
license_public_key_path: env::var("LICENSE_PUBLIC_KEY_PATH")
|
||||
.unwrap_or_else(|_| "./keys/public.pem".into()),
|
||||
license_token_ttl_days: env::var("LICENSE_TOKEN_TTL_DAYS")
|
||||
.unwrap_or_else(|_| "30".into())
|
||||
.parse()
|
||||
.context("LICENSE_TOKEN_TTL_DAYS must be a number")?,
|
||||
license_grace_period_days: env::var("LICENSE_GRACE_PERIOD_DAYS")
|
||||
.unwrap_or_else(|_| "7".into())
|
||||
.parse()
|
||||
.context("LICENSE_GRACE_PERIOD_DAYS must be a number")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub async fn create_pool(cfg: &Config) -> Result<MySqlPool> {
|
||||
let pool = MySqlPoolOptions::new()
|
||||
.max_connections(20)
|
||||
.connect(&cfg.database_url)
|
||||
.await?;
|
||||
|
||||
// Run embedded migrations
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
#[error("Not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
AppError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, m.clone()),
|
||||
AppError::Forbidden(m) => (StatusCode::FORBIDDEN, m.clone()),
|
||||
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
|
||||
AppError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
|
||||
AppError::Database(e) => {
|
||||
tracing::error!("Database error: {e}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Database error".into())
|
||||
}
|
||||
AppError::Internal(e) => {
|
||||
tracing::error!("Internal error: {e}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".into())
|
||||
}
|
||||
};
|
||||
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
mod config;
|
||||
mod db;
|
||||
mod error;
|
||||
mod middleware;
|
||||
mod models;
|
||||
mod routes;
|
||||
mod services;
|
||||
mod state;
|
||||
|
||||
use anyhow::Result;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Structured logging
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "nixpanel_licensing=info,tower_http=info".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let cfg = config::Config::from_env()?;
|
||||
let pool = db::create_pool(&cfg).await?;
|
||||
|
||||
tracing::info!("Database connected and migrations applied");
|
||||
|
||||
let state = state::AppState::new(pool, cfg.clone());
|
||||
let app = routes::create_router(state)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CorsLayer::permissive()); // tighten via env in production
|
||||
|
||||
let addr = format!("{}:{}", cfg.host, cfg.port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
tracing::info!("NixPanel Licensing Server listening on {addr}");
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use axum::{
|
||||
extract::{FromRequestParts, State},
|
||||
http::{request::Parts, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{models::AdminClaims, services::crypto::verify_admin_jwt, state::AppState};
|
||||
|
||||
// ── Extractor: any authenticated admin ───────────────────────────────────────
|
||||
|
||||
pub struct AuthAdmin(pub AdminClaims);
|
||||
|
||||
impl FromRequestParts<AppState> for AuthAdmin {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
let token = bearer_token(parts).ok_or_else(|| {
|
||||
(StatusCode::UNAUTHORIZED, Json(json!({"error": "Missing authorization header"})))
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
let claims = verify_admin_jwt(&state.config, token).map_err(|_| {
|
||||
(StatusCode::UNAUTHORIZED, Json(json!({"error": "Invalid or expired token"})))
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
Ok(AuthAdmin(claims))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extractor: superadmin only ────────────────────────────────────────────────
|
||||
|
||||
pub struct AuthSuperAdmin(pub AdminClaims);
|
||||
|
||||
impl FromRequestParts<AppState> for AuthSuperAdmin {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
let AuthAdmin(claims) = AuthAdmin::from_request_parts(parts, state).await?;
|
||||
if claims.role != "superadmin" {
|
||||
return Err((StatusCode::FORBIDDEN, Json(json!({"error": "Superadmin access required"})))
|
||||
.into_response());
|
||||
}
|
||||
Ok(AuthSuperAdmin(claims))
|
||||
}
|
||||
}
|
||||
|
||||
fn bearer_token<'a>(parts: &'a Parts) -> Option<&'a str> {
|
||||
parts
|
||||
.headers
|
||||
.get("Authorization")?
|
||||
.to_str()
|
||||
.ok()?
|
||||
.strip_prefix("Bearer ")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod auth;
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
// ── Plans ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Plan {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub max_domains: i32,
|
||||
pub max_accounts: i32,
|
||||
pub max_emails: i32,
|
||||
pub max_databases: i32,
|
||||
pub max_disk_gb: i32,
|
||||
pub price_monthly: f64,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
// ── Licenses ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type, PartialEq)]
|
||||
#[sqlx(type_name = "VARCHAR", rename_all = "lowercase")]
|
||||
pub enum LicenseStatus {
|
||||
Active,
|
||||
Suspended,
|
||||
Expired,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LicenseStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
LicenseStatus::Active => "active",
|
||||
LicenseStatus::Suspended => "suspended",
|
||||
LicenseStatus::Expired => "expired",
|
||||
LicenseStatus::Revoked => "revoked",
|
||||
};
|
||||
write!(f, "{s}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct License {
|
||||
pub id: String,
|
||||
pub plan_id: String,
|
||||
pub owner_email: String,
|
||||
pub owner_name: String,
|
||||
pub server_ip: Option<String>,
|
||||
pub server_hostname: Option<String>,
|
||||
pub server_fingerprint: Option<String>,
|
||||
pub status: String,
|
||||
pub issued_at: NaiveDateTime,
|
||||
pub expires_at: Option<NaiveDateTime>,
|
||||
pub last_verified: Option<NaiveDateTime>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LicenseWithPlan {
|
||||
#[serde(flatten)]
|
||||
pub license: License,
|
||||
pub plan: Plan,
|
||||
}
|
||||
|
||||
// ── Admins ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Admin {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
pub is_active: bool,
|
||||
pub last_login: Option<NaiveDateTime>,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
// ── JWT Claims ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AdminClaims {
|
||||
pub sub: String, // admin id
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LicenseTokenClaims {
|
||||
pub license_id: String,
|
||||
pub plan_id: String,
|
||||
pub owner_email: String,
|
||||
pub server_ip: Option<String>,
|
||||
pub fingerprint: Option<String>,
|
||||
pub limits: LicenseLimits,
|
||||
pub issued_at: i64,
|
||||
pub exp: usize,
|
||||
pub grace_days: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LicenseLimits {
|
||||
pub max_domains: i32,
|
||||
pub max_accounts: i32,
|
||||
pub max_emails: i32,
|
||||
pub max_databases: i32,
|
||||
pub max_disk_gb: i32,
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ValidationResult {
|
||||
Valid,
|
||||
Invalid,
|
||||
Expired,
|
||||
Revoked,
|
||||
FingerprintMismatch,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ValidationResult {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
ValidationResult::Valid => "valid",
|
||||
ValidationResult::Invalid => "invalid",
|
||||
ValidationResult::Expired => "expired",
|
||||
ValidationResult::Revoked => "revoked",
|
||||
ValidationResult::FingerprintMismatch => "fingerprint_mismatch",
|
||||
};
|
||||
write!(f, "{s}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
error::AppResult,
|
||||
middleware::auth::{AuthAdmin, AuthSuperAdmin},
|
||||
services::admin::{create_admin, login_admin},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateAdminRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/login
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let token = login_admin(&state.pool, &state.config, &body.username, &body.password).await?;
|
||||
Ok(Json(serde_json::json!({ "token": token })))
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/users (superadmin only)
|
||||
pub async fn create_user(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthSuperAdmin,
|
||||
Json(body): Json<CreateAdminRequest>,
|
||||
) -> AppResult<(StatusCode, Json<serde_json::Value>)> {
|
||||
let admin = create_admin(&state.pool, &body.username, &body.password, &body.email, &body.role).await?;
|
||||
Ok((StatusCode::CREATED, Json(serde_json::json!({
|
||||
"id": admin.id, "username": admin.username, "role": admin.role
|
||||
}))))
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/stats
|
||||
pub async fn stats(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthAdmin,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let license_counts = sqlx::query!(
|
||||
"SELECT status, COUNT(*) as count FROM licenses GROUP BY status"
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let plan_counts = sqlx::query!(
|
||||
r#"SELECT p.name, COUNT(l.id) as count
|
||||
FROM plans p LEFT JOIN licenses l ON p.id = l.plan_id
|
||||
GROUP BY p.id, p.name"#
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let recent_validations = sqlx::query!(
|
||||
r#"SELECT result, COUNT(*) as count
|
||||
FROM validation_log
|
||||
WHERE checked_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
|
||||
GROUP BY result"#
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"licenses": license_counts.iter().map(|r| serde_json::json!({
|
||||
"status": r.status, "count": r.count
|
||||
})).collect::<Vec<_>>(),
|
||||
"plans": plan_counts.iter().map(|r| serde_json::json!({
|
||||
"name": r.name, "count": r.count
|
||||
})).collect::<Vec<_>>(),
|
||||
"recent_validations": recent_validations.iter().map(|r| serde_json::json!({
|
||||
"result": r.result, "count": r.count
|
||||
})).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/plans
|
||||
pub async fn plans(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthAdmin,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let plans = sqlx::query!("SELECT * FROM plans ORDER BY price_monthly ASC")
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let result: Vec<serde_json::Value> = plans.into_iter().map(|p| serde_json::json!({
|
||||
"id": p.id, "name": p.name,
|
||||
"max_domains": p.max_domains, "max_accounts": p.max_accounts,
|
||||
"max_emails": p.max_emails, "max_databases": p.max_databases,
|
||||
"max_disk_gb": p.max_disk_gb, "price_monthly": p.price_monthly,
|
||||
"is_active": p.is_active,
|
||||
})).collect();
|
||||
|
||||
Ok(Json(serde_json::json!(result)))
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/ip/:ip
|
||||
pub async fn lookup_ip(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthAdmin,
|
||||
axum::extract::Path(ip): axum::extract::Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
use crate::services::license::get_license_by_ip;
|
||||
let license = get_license_by_ip(&state.pool, &ip)
|
||||
.await?
|
||||
.ok_or(crate::error::AppError::NotFound)?;
|
||||
Ok(Json(serde_json::to_value(&license).unwrap()))
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/ip-conflicts
|
||||
pub async fn ip_conflicts(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthAdmin,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let conflicts = sqlx::query!(
|
||||
r#"SELECT server_ip, COUNT(*) as license_count, GROUP_CONCAT(id) as license_ids
|
||||
FROM licenses WHERE server_ip IS NOT NULL AND status = 'active'
|
||||
GROUP BY server_ip HAVING COUNT(*) > 1"#
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let result: Vec<serde_json::Value> = conflicts.into_iter().map(|r| serde_json::json!({
|
||||
"server_ip": r.server_ip, "license_count": r.license_count, "license_ids": r.license_ids,
|
||||
})).collect();
|
||||
|
||||
Ok(Json(serde_json::json!(result)))
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
middleware::auth::AuthAdmin,
|
||||
models::License,
|
||||
services::license::{
|
||||
activate_license, create_license, get_license_by_id,
|
||||
issue_token, revoke_license, suspend_license,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateLicenseRequest {
|
||||
pub plan_id: String,
|
||||
pub owner_email: String,
|
||||
pub owner_name: String,
|
||||
pub server_ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
// GET /api/v1/licenses
|
||||
pub async fn list(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthAdmin,
|
||||
) -> AppResult<Json<Vec<serde_json::Value>>> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT l.*, p.name as plan_name
|
||||
FROM licenses l JOIN plans p ON l.plan_id = p.id
|
||||
ORDER BY l.issued_at DESC"#
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let result: Vec<serde_json::Value> = rows.into_iter().map(|r| serde_json::json!({
|
||||
"id": r.id,
|
||||
"owner_email": r.owner_email,
|
||||
"owner_name": r.owner_name,
|
||||
"plan_name": r.plan_name,
|
||||
"server_ip": r.server_ip,
|
||||
"status": r.status,
|
||||
"issued_at": r.issued_at.to_string(),
|
||||
"expires_at": r.expires_at.map(|d| d.to_string()),
|
||||
"last_verified":r.last_verified.map(|d| d.to_string()),
|
||||
})).collect();
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// GET /api/v1/licenses/:id
|
||||
pub async fn get(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthAdmin,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let lp = get_license_by_id(&state.pool, &id)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
Ok(Json(serde_json::to_value(&lp).unwrap()))
|
||||
}
|
||||
|
||||
// POST /api/v1/licenses
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthAdmin,
|
||||
Json(body): Json<CreateLicenseRequest>,
|
||||
) -> AppResult<(StatusCode, Json<License>)> {
|
||||
let expires_at = body.expires_at
|
||||
.as_deref()
|
||||
.map(|s| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
|
||||
.transpose()
|
||||
.map_err(|_| AppError::BadRequest("Invalid expires_at format".into()))?;
|
||||
|
||||
let license = create_license(
|
||||
&state.pool,
|
||||
&body.plan_id,
|
||||
&body.owner_email,
|
||||
&body.owner_name,
|
||||
body.server_ip.as_deref(),
|
||||
body.hostname.as_deref(),
|
||||
None,
|
||||
expires_at,
|
||||
body.notes.as_deref(),
|
||||
).await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(license)))
|
||||
}
|
||||
|
||||
// POST /api/v1/licenses/:id/issue
|
||||
pub async fn issue(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthAdmin,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let token = issue_token(&state.pool, &state.config, &id).await?;
|
||||
Ok(Json(serde_json::json!({ "token": token })))
|
||||
}
|
||||
|
||||
// POST /api/v1/licenses/:id/revoke
|
||||
pub async fn revoke(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthAdmin,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
revoke_license(&state.pool, &id).await?;
|
||||
Ok(Json(serde_json::json!({ "success": true })))
|
||||
}
|
||||
|
||||
// POST /api/v1/licenses/:id/suspend
|
||||
pub async fn suspend(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthAdmin,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
suspend_license(&state.pool, &id).await?;
|
||||
Ok(Json(serde_json::json!({ "success": true })))
|
||||
}
|
||||
|
||||
// POST /api/v1/licenses/:id/activate
|
||||
pub async fn activate(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthAdmin,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
activate_license(&state.pool, &id).await?;
|
||||
Ok(Json(serde_json::json!({ "success": true })))
|
||||
}
|
||||
|
||||
// GET /api/v1/licenses/:id/logs
|
||||
pub async fn logs(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthAdmin,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let rows = sqlx::query!(
|
||||
"SELECT * FROM validation_log WHERE license_id = ? ORDER BY checked_at DESC LIMIT 200",
|
||||
id
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let result: Vec<serde_json::Value> = rows.into_iter().map(|r| serde_json::json!({
|
||||
"id": r.id,
|
||||
"server_ip": r.server_ip,
|
||||
"result": r.result,
|
||||
"checked_at": r.checked_at.to_string(),
|
||||
})).collect();
|
||||
|
||||
Ok(Json(serde_json::json!(result)))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
mod admin;
|
||||
mod licenses;
|
||||
mod validate;
|
||||
|
||||
pub fn create_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.nest("/api/v1", api_router())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn api_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Public — called by NixPanel deployments
|
||||
.route("/validate", post(validate::validate))
|
||||
.route("/activate", post(validate::activate))
|
||||
// License management — admin auth required
|
||||
.nest("/licenses", license_router())
|
||||
// Admin
|
||||
.nest("/admin", admin_router())
|
||||
}
|
||||
|
||||
fn license_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(licenses::list).post(licenses::create))
|
||||
.route("/:id", get(licenses::get))
|
||||
.route("/:id/issue", post(licenses::issue))
|
||||
.route("/:id/revoke", post(licenses::revoke))
|
||||
.route("/:id/suspend", post(licenses::suspend))
|
||||
.route("/:id/activate", post(licenses::activate))
|
||||
.route("/:id/logs", get(licenses::logs))
|
||||
}
|
||||
|
||||
fn admin_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/login", post(admin::login))
|
||||
.route("/users", post(admin::create_user))
|
||||
.route("/stats", get(admin::stats))
|
||||
.route("/plans", get(admin::plans))
|
||||
.route("/ip/:ip", get(admin::lookup_ip))
|
||||
.route("/ip-conflicts", get(admin::ip_conflicts))
|
||||
}
|
||||
|
||||
async fn health() -> axum::Json<serde_json::Value> {
|
||||
axum::Json(serde_json::json!({ "status": "ok", "ts": chrono::Utc::now().to_rfc3339() }))
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
error::AppResult,
|
||||
models::ValidationResult,
|
||||
services::license::{issue_token, validate_license},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ValidateRequest {
|
||||
pub license_id: String,
|
||||
pub server_ip: String,
|
||||
pub fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ActivateRequest {
|
||||
pub license_id: String,
|
||||
pub server_ip: String,
|
||||
pub hostname: String,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
// POST /api/v1/validate
|
||||
pub async fn validate(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ValidateRequest>,
|
||||
) -> AppResult<impl axum::response::IntoResponse> {
|
||||
let (result, license) = validate_license(
|
||||
&state.pool,
|
||||
&body.license_id,
|
||||
&body.server_ip,
|
||||
body.fingerprint.as_deref(),
|
||||
).await?;
|
||||
|
||||
if result == ValidationResult::Valid {
|
||||
let lp = license.unwrap();
|
||||
return Ok((StatusCode::OK, Json(serde_json::json!({
|
||||
"valid": true,
|
||||
"result": result,
|
||||
"plan": lp.plan.name,
|
||||
"limits": lp.plan,
|
||||
"grace_days": state.config.license_grace_period_days,
|
||||
}))));
|
||||
}
|
||||
|
||||
Ok((StatusCode::FORBIDDEN, Json(serde_json::json!({
|
||||
"valid": false,
|
||||
"result": result,
|
||||
}))))
|
||||
}
|
||||
|
||||
// POST /api/v1/activate
|
||||
pub async fn activate(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ActivateRequest>,
|
||||
) -> AppResult<impl axum::response::IntoResponse> {
|
||||
let (result, _) = validate_license(
|
||||
&state.pool,
|
||||
&body.license_id,
|
||||
&body.server_ip,
|
||||
Some(&body.fingerprint),
|
||||
).await?;
|
||||
|
||||
// Allow activation even if fingerprint not yet registered (first bind)
|
||||
if result != ValidationResult::Valid && result != ValidationResult::FingerprintMismatch {
|
||||
return Ok((StatusCode::FORBIDDEN, Json(serde_json::json!({"error": result.to_string()}))));
|
||||
}
|
||||
|
||||
let token = issue_token(&state.pool, &state.config, &body.license_id).await?;
|
||||
Ok((StatusCode::OK, Json(serde_json::json!({ "token": token }))))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
error::{AppError, AppResult},
|
||||
models::{Admin, AdminClaims},
|
||||
services::crypto::{issue_admin_jwt, new_id},
|
||||
};
|
||||
|
||||
pub async fn get_admin_by_username(pool: &MySqlPool, username: &str) -> AppResult<Option<Admin>> {
|
||||
Ok(sqlx::query_as!(
|
||||
Admin,
|
||||
"SELECT * FROM admins WHERE username = ? AND is_active = 1",
|
||||
username
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_admin_by_id(pool: &MySqlPool, id: &str) -> AppResult<Option<Admin>> {
|
||||
Ok(sqlx::query_as!(Admin, "SELECT * FROM admins WHERE id = ?", id)
|
||||
.fetch_optional(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn create_admin(
|
||||
pool: &MySqlPool,
|
||||
username: &str,
|
||||
password: &str,
|
||||
email: &str,
|
||||
role: &str,
|
||||
) -> AppResult<Admin> {
|
||||
let id = new_id();
|
||||
let hash = hash_password(password)?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO admins (id, username, password_hash, email, role) VALUES (?, ?, ?, ?, ?)",
|
||||
id, username, hash, email, role
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(sqlx::query_as!(Admin, "SELECT * FROM admins WHERE id = ?", id)
|
||||
.fetch_one(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub fn verify_password(hash: &str, password: &str) -> bool {
|
||||
let Ok(parsed) = PasswordHash::new(hash) else { return false };
|
||||
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
|
||||
}
|
||||
|
||||
pub async fn login_admin(
|
||||
pool: &MySqlPool,
|
||||
cfg: &Config,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> AppResult<String> {
|
||||
let admin = get_admin_by_username(pool, username)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Unauthorized("Invalid credentials".into()))?;
|
||||
|
||||
if !verify_password(&admin.password_hash, password) {
|
||||
return Err(AppError::Unauthorized("Invalid credentials".into()));
|
||||
}
|
||||
|
||||
sqlx::query!("UPDATE admins SET last_login = NOW() WHERE id = ?", admin.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let exp = (Utc::now().timestamp() as u64 + cfg.jwt_expires_in_secs) as usize;
|
||||
let claims = AdminClaims {
|
||||
sub: admin.id,
|
||||
username: admin.username,
|
||||
role: admin.role,
|
||||
exp,
|
||||
};
|
||||
|
||||
issue_admin_jwt(cfg, claims).map_err(|e| AppError::Internal(e))
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> AppResult<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|h| h.to_string())
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password hashing failed: {e}")))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use anyhow::{Context, Result};
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use rsa::{pkcs8::{DecodePrivateKey, DecodePublicKey}, RsaPrivateKey, RsaPublicKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fs, sync::OnceLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{config::Config, models::{AdminClaims, LicenseTokenClaims}};
|
||||
|
||||
// Keys are loaded once at first use
|
||||
static PRIVATE_KEY: OnceLock<EncodingKey> = OnceLock::new();
|
||||
static PUBLIC_KEY: OnceLock<DecodingKey> = OnceLock::new();
|
||||
|
||||
fn load_encoding_key(path: &str) -> Result<EncodingKey> {
|
||||
let pem = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read private key from {path}"))?;
|
||||
EncodingKey::from_rsa_pem(pem.as_bytes())
|
||||
.context("Failed to parse RSA private key")
|
||||
}
|
||||
|
||||
fn load_decoding_key(path: &str) -> Result<DecodingKey> {
|
||||
let pem = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read public key from {path}"))?;
|
||||
DecodingKey::from_rsa_pem(pem.as_bytes())
|
||||
.context("Failed to parse RSA public key")
|
||||
}
|
||||
|
||||
pub fn issue_license_token(cfg: &Config, claims: LicenseTokenClaims) -> Result<String> {
|
||||
let key = PRIVATE_KEY.get_or_try_init(|| load_encoding_key(&cfg.license_private_key_path))?;
|
||||
encode(&Header::new(Algorithm::RS256), &claims, key)
|
||||
.context("Failed to sign license token")
|
||||
}
|
||||
|
||||
pub fn verify_license_token(cfg: &Config, token: &str) -> Result<LicenseTokenClaims> {
|
||||
let key = PUBLIC_KEY.get_or_try_init(|| load_decoding_key(&cfg.license_public_key_path))?;
|
||||
let mut validation = Validation::new(Algorithm::RS256);
|
||||
validation.validate_exp = true;
|
||||
decode::<LicenseTokenClaims>(token, key, &validation)
|
||||
.map(|d| d.claims)
|
||||
.context("Invalid or expired license token")
|
||||
}
|
||||
|
||||
pub fn issue_admin_jwt(cfg: &Config, claims: AdminClaims) -> Result<String> {
|
||||
let key = EncodingKey::from_secret(cfg.jwt_secret.as_bytes());
|
||||
encode(&Header::default(), &claims, &key)
|
||||
.context("Failed to sign admin JWT")
|
||||
}
|
||||
|
||||
pub fn verify_admin_jwt(cfg: &Config, token: &str) -> Result<AdminClaims> {
|
||||
let key = DecodingKey::from_secret(cfg.jwt_secret.as_bytes());
|
||||
decode::<AdminClaims>(token, &key, &Validation::default())
|
||||
.map(|d| d.claims)
|
||||
.context("Invalid or expired admin token")
|
||||
}
|
||||
|
||||
pub fn hash_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn new_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
pub fn server_fingerprint(ip: &str, hostname: &str, mac: Option<&str>, cpu_id: Option<&str>) -> String {
|
||||
let raw = format!("{ip}|{hostname}|{}|{}", mac.unwrap_or(""), cpu_id.unwrap_or(""));
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(raw.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
use anyhow::anyhow;
|
||||
use chrono::Utc;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
error::{AppError, AppResult},
|
||||
models::{License, LicenseLimits, LicenseTokenClaims, LicenseWithPlan, Plan, ValidationResult},
|
||||
services::crypto::{hash_token, issue_license_token, new_id},
|
||||
};
|
||||
|
||||
// ── Queries ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_license_by_id(pool: &MySqlPool, id: &str) -> AppResult<Option<LicenseWithPlan>> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT l.id, l.plan_id, l.owner_email, l.owner_name,
|
||||
l.server_ip, l.server_hostname, l.server_fingerprint,
|
||||
l.status, l.issued_at, l.expires_at, l.last_verified, l.notes,
|
||||
p.name as plan_name, p.max_domains, p.max_accounts,
|
||||
p.max_emails, p.max_databases, p.max_disk_gb, p.price_monthly,
|
||||
p.is_active as plan_active
|
||||
FROM licenses l JOIN plans p ON l.plan_id = p.id
|
||||
WHERE l.id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| LicenseWithPlan {
|
||||
license: License {
|
||||
id: r.id,
|
||||
plan_id: r.plan_id,
|
||||
owner_email: r.owner_email,
|
||||
owner_name: r.owner_name,
|
||||
server_ip: r.server_ip,
|
||||
server_hostname: r.server_hostname,
|
||||
server_fingerprint: r.server_fingerprint,
|
||||
status: r.status,
|
||||
issued_at: r.issued_at,
|
||||
expires_at: r.expires_at,
|
||||
last_verified: r.last_verified,
|
||||
notes: r.notes,
|
||||
},
|
||||
plan: Plan {
|
||||
id: r.plan_id.clone(),
|
||||
name: r.plan_name,
|
||||
max_domains: r.max_domains,
|
||||
max_accounts: r.max_accounts,
|
||||
max_emails: r.max_emails,
|
||||
max_databases: r.max_databases,
|
||||
max_disk_gb: r.max_disk_gb,
|
||||
price_monthly: r.price_monthly,
|
||||
is_active: r.plan_active != 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_license_by_ip(pool: &MySqlPool, ip: &str) -> AppResult<Option<License>> {
|
||||
Ok(sqlx::query_as!(
|
||||
License,
|
||||
"SELECT * FROM licenses WHERE server_ip = ? AND status = 'active' LIMIT 1",
|
||||
ip
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn create_license(
|
||||
pool: &MySqlPool,
|
||||
plan_id: &str,
|
||||
owner_email: &str,
|
||||
owner_name: &str,
|
||||
server_ip: Option<&str>,
|
||||
hostname: Option<&str>,
|
||||
fingerprint: Option<&str>,
|
||||
expires_at: Option<chrono::NaiveDateTime>,
|
||||
notes: Option<&str>,
|
||||
) -> AppResult<License> {
|
||||
let id = new_id();
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO licenses
|
||||
(id, plan_id, owner_email, owner_name, server_ip,
|
||||
server_hostname, server_fingerprint, expires_at, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||
id, plan_id, owner_email, owner_name,
|
||||
server_ip, hostname, fingerprint, expires_at, notes
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(sqlx::query_as!(License, "SELECT * FROM licenses WHERE id = ?", id)
|
||||
.fetch_one(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn issue_token(pool: &MySqlPool, cfg: &Config, license_id: &str) -> AppResult<String> {
|
||||
let lp = get_license_by_id(pool, license_id)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
if lp.license.status != "active" {
|
||||
return Err(AppError::BadRequest(format!("License is {}", lp.license.status)));
|
||||
}
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
let exp_secs = cfg.license_token_ttl_days * 86_400;
|
||||
let exp = (now + exp_secs as i64) as usize;
|
||||
|
||||
let claims = LicenseTokenClaims {
|
||||
license_id: lp.license.id.clone(),
|
||||
plan_id: lp.license.plan_id.clone(),
|
||||
owner_email: lp.license.owner_email.clone(),
|
||||
server_ip: lp.license.server_ip.clone(),
|
||||
fingerprint: lp.license.server_fingerprint.clone(),
|
||||
limits: LicenseLimits {
|
||||
max_domains: lp.plan.max_domains,
|
||||
max_accounts: lp.plan.max_accounts,
|
||||
max_emails: lp.plan.max_emails,
|
||||
max_databases: lp.plan.max_databases,
|
||||
max_disk_gb: lp.plan.max_disk_gb,
|
||||
},
|
||||
issued_at: now,
|
||||
exp,
|
||||
grace_days: cfg.license_grace_period_days,
|
||||
};
|
||||
|
||||
let token = issue_license_token(cfg, claims).map_err(|e| AppError::Internal(e))?;
|
||||
let token_id = new_id();
|
||||
let tok_hash = hash_token(&token);
|
||||
let expires = chrono::DateTime::from_timestamp(exp as i64, 0)
|
||||
.unwrap()
|
||||
.naive_utc();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO license_tokens (id, license_id, token_hash, expires_at) VALUES (?, ?, ?, ?)",
|
||||
token_id, license_id, tok_hash, expires
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("UPDATE licenses SET last_verified = NOW() WHERE id = ?", license_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub async fn validate_license(
|
||||
pool: &MySqlPool,
|
||||
license_id: &str,
|
||||
server_ip: &str,
|
||||
fingerprint: Option<&str>,
|
||||
) -> AppResult<(ValidationResult, Option<LicenseWithPlan>)> {
|
||||
let Some(lp) = get_license_by_id(pool, license_id).await? else {
|
||||
log_validation(pool, license_id, server_ip, fingerprint, &ValidationResult::Invalid).await;
|
||||
return Ok((ValidationResult::Invalid, None));
|
||||
};
|
||||
|
||||
let result = match lp.license.status.as_str() {
|
||||
"revoked" | "suspended" => ValidationResult::Revoked,
|
||||
"expired" => ValidationResult::Expired,
|
||||
_ if lp.license.expires_at
|
||||
.map(|e| e < Utc::now().naive_utc())
|
||||
.unwrap_or(false) => ValidationResult::Expired,
|
||||
_ => {
|
||||
// IP conflict check
|
||||
if let Some(existing) = get_license_by_ip(pool, server_ip).await? {
|
||||
if existing.id != lp.license.id {
|
||||
log_validation(pool, license_id, server_ip, fingerprint, &ValidationResult::FingerprintMismatch).await;
|
||||
return Ok((ValidationResult::FingerprintMismatch, None));
|
||||
}
|
||||
}
|
||||
// Fingerprint check
|
||||
if let (Some(reg), Some(given)) = (lp.license.server_fingerprint.as_deref(), fingerprint) {
|
||||
if reg != given {
|
||||
log_validation(pool, license_id, server_ip, fingerprint, &ValidationResult::FingerprintMismatch).await;
|
||||
return Ok((ValidationResult::FingerprintMismatch, None));
|
||||
}
|
||||
}
|
||||
ValidationResult::Valid
|
||||
}
|
||||
};
|
||||
|
||||
log_validation(pool, license_id, server_ip, fingerprint, &result).await;
|
||||
|
||||
if result == ValidationResult::Valid {
|
||||
sqlx::query!(
|
||||
"UPDATE licenses SET last_verified = NOW(), server_ip = ? WHERE id = ?",
|
||||
server_ip, license_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
return Ok((result, Some(lp)));
|
||||
}
|
||||
|
||||
Ok((result, None))
|
||||
}
|
||||
|
||||
pub async fn revoke_license(pool: &MySqlPool, id: &str) -> AppResult<()> {
|
||||
sqlx::query!("UPDATE licenses SET status = 'revoked' WHERE id = ?", id)
|
||||
.execute(pool).await?;
|
||||
sqlx::query!("UPDATE license_tokens SET revoked = 1, revoked_at = NOW() WHERE license_id = ?", id)
|
||||
.execute(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn suspend_license(pool: &MySqlPool, id: &str) -> AppResult<()> {
|
||||
sqlx::query!("UPDATE licenses SET status = 'suspended' WHERE id = ?", id)
|
||||
.execute(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn activate_license(pool: &MySqlPool, id: &str) -> AppResult<()> {
|
||||
sqlx::query!("UPDATE licenses SET status = 'active' WHERE id = ?", id)
|
||||
.execute(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn log_validation(
|
||||
pool: &MySqlPool,
|
||||
license_id: &str,
|
||||
server_ip: &str,
|
||||
fingerprint: Option<&str>,
|
||||
result: &ValidationResult,
|
||||
) {
|
||||
let _ = sqlx::query!(
|
||||
"INSERT INTO validation_log (license_id, server_ip, server_fingerprint, result) VALUES (?, ?, ?, ?)",
|
||||
license_id, server_ip, fingerprint, result.to_string()
|
||||
)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod admin;
|
||||
pub mod crypto;
|
||||
pub mod license;
|
||||
@@ -0,0 +1,16 @@
|
||||
use sqlx::MySqlPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: MySqlPool,
|
||||
pub config: Arc<Config>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(pool: MySqlPool, config: Config) -> Self {
|
||||
Self { pool, config: Arc::new(config) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user