feat: full Rust rewrite — axum, sqlx, RSA-4096 license signing, argon2, MariaDB

This commit is contained in:
NixReaper
2026-04-23 17:22:00 -05:00
commit a1b35a6ea5
21 changed files with 1375 additions and 0 deletions
+17
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
target/
.env
keys/
*.pem
*.log
+58
View File
@@ -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
+73
View File
@@ -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);
+32
View File
@@ -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");
}
+49
View File
@@ -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")?,
})
}
}
+17
View File
@@ -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)
}
+51
View File
@@ -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
View File
@@ -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(())
}
+57
View File
@@ -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 ")
}
+1
View File
@@ -0,0 +1 @@
pub mod auth;
+136
View File
@@ -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}")
}
}
+138
View File
@@ -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)))
}
+158
View File
@@ -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)))
}
+53
View File
@@ -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() }))
}
+74
View File
@@ -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 }))))
}
+94
View File
@@ -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}")))
}
+71
View File
@@ -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())
}
+232
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
pub mod admin;
pub mod crypto;
pub mod license;
+16
View File
@@ -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) }
}
}