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