From a1b35a6ea5e8eebd38abc34f913b2ac2fe6a985a Mon Sep 17 00:00:00 2001 From: NixReaper Date: Thu, 23 Apr 2026 17:22:00 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20full=20Rust=20rewrite=20=E2=80=94=20axu?= =?UTF-8?q?m,=20sqlx,=20RSA-4096=20license=20signing,=20argon2,=20MariaDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 17 ++ .gitignore | 5 + Cargo.toml | 58 +++++++ migrations/20240101000000_initial.sql | 73 ++++++++ src/bin/keygen.rs | 32 ++++ src/config.rs | 49 ++++++ src/db.rs | 17 ++ src/error.rs | 51 ++++++ src/main.rs | 40 +++++ src/middleware/auth.rs | 57 +++++++ src/middleware/mod.rs | 1 + src/models.rs | 136 +++++++++++++++ src/routes/admin.rs | 138 +++++++++++++++ src/routes/licenses.rs | 158 ++++++++++++++++++ src/routes/mod.rs | 53 ++++++ src/routes/validate.rs | 74 ++++++++ src/services/admin.rs | 94 +++++++++++ src/services/crypto.rs | 71 ++++++++ src/services/license.rs | 232 ++++++++++++++++++++++++++ src/services/mod.rs | 3 + src/state.rs | 16 ++ 21 files changed, 1375 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 migrations/20240101000000_initial.sql create mode 100644 src/bin/keygen.rs create mode 100644 src/config.rs create mode 100644 src/db.rs create mode 100644 src/error.rs create mode 100644 src/main.rs create mode 100644 src/middleware/auth.rs create mode 100644 src/middleware/mod.rs create mode 100644 src/models.rs create mode 100644 src/routes/admin.rs create mode 100644 src/routes/licenses.rs create mode 100644 src/routes/mod.rs create mode 100644 src/routes/validate.rs create mode 100644 src/services/admin.rs create mode 100644 src/services/crypto.rs create mode 100644 src/services/license.rs create mode 100644 src/services/mod.rs create mode 100644 src/state.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a81eac1 --- /dev/null +++ b/.env.example @@ -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:password@127.0.0.1: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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f93de3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +.env +keys/ +*.pem +*.log diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4847dc6 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/migrations/20240101000000_initial.sql b/migrations/20240101000000_initial.sql new file mode 100644 index 0000000..f66bf0a --- /dev/null +++ b/migrations/20240101000000_initial.sql @@ -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); diff --git a/src/bin/keygen.rs b/src/bin/keygen.rs new file mode 100644 index 0000000..efd3333 --- /dev/null +++ b/src/bin/keygen.rs @@ -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"); +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..965bd00 --- /dev/null +++ b/src/config.rs @@ -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 { + 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")?, + }) + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..294996a --- /dev/null +++ b/src/db.rs @@ -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 { + let pool = MySqlPoolOptions::new() + .max_connections(20) + .connect(&cfg.database_url) + .await?; + + // Run embedded migrations + sqlx::migrate!("./migrations").run(&pool).await?; + + Ok(pool) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..0650790 --- /dev/null +++ b/src/error.rs @@ -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 = Result; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9e228b9 --- /dev/null +++ b/src/main.rs @@ -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(()) +} diff --git a/src/middleware/auth.rs b/src/middleware/auth.rs new file mode 100644 index 0000000..4492f0a --- /dev/null +++ b/src/middleware/auth.rs @@ -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 for AuthAdmin { + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + 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 for AuthSuperAdmin { + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + 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 ") +} diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/src/middleware/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..5a93bf7 --- /dev/null +++ b/src/models.rs @@ -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, + pub server_hostname: Option, + pub server_fingerprint: Option, + pub status: String, + pub issued_at: NaiveDateTime, + pub expires_at: Option, + pub last_verified: Option, + pub notes: Option, +} + +#[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, + 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, + pub fingerprint: Option, + 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}") + } +} diff --git a/src/routes/admin.rs b/src/routes/admin.rs new file mode 100644 index 0000000..9514be8 --- /dev/null +++ b/src/routes/admin.rs @@ -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, + Json(body): Json, +) -> AppResult> { + 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, + _auth: AuthSuperAdmin, + Json(body): Json, +) -> AppResult<(StatusCode, Json)> { + 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, + _auth: AuthAdmin, +) -> AppResult> { + 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::>(), + "plans": plan_counts.iter().map(|r| serde_json::json!({ + "name": r.name, "count": r.count + })).collect::>(), + "recent_validations": recent_validations.iter().map(|r| serde_json::json!({ + "result": r.result, "count": r.count + })).collect::>(), + }))) +} + +// GET /api/v1/admin/plans +pub async fn plans( + State(state): State, + _auth: AuthAdmin, +) -> AppResult> { + let plans = sqlx::query!("SELECT * FROM plans ORDER BY price_monthly ASC") + .fetch_all(&state.pool) + .await?; + + let result: Vec = 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, + _auth: AuthAdmin, + axum::extract::Path(ip): axum::extract::Path, +) -> AppResult> { + 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, + _auth: AuthAdmin, +) -> AppResult> { + 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 = 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))) +} diff --git a/src/routes/licenses.rs b/src/routes/licenses.rs new file mode 100644 index 0000000..75b19ae --- /dev/null +++ b/src/routes/licenses.rs @@ -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, + pub hostname: Option, + pub expires_at: Option, + pub notes: Option, +} + +// GET /api/v1/licenses +pub async fn list( + State(state): State, + _auth: AuthAdmin, +) -> AppResult>> { + 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 = 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, + _auth: AuthAdmin, + Path(id): Path, +) -> AppResult> { + 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, + _auth: AuthAdmin, + Json(body): Json, +) -> AppResult<(StatusCode, Json)> { + 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, + _auth: AuthAdmin, + Path(id): Path, +) -> AppResult> { + 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, + _auth: AuthAdmin, + Path(id): Path, +) -> AppResult> { + 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, + _auth: AuthAdmin, + Path(id): Path, +) -> AppResult> { + 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, + _auth: AuthAdmin, + Path(id): Path, +) -> AppResult> { + 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, + _auth: AuthAdmin, + Path(id): Path, +) -> AppResult> { + 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 = 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))) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..8f96062 --- /dev/null +++ b/src/routes/mod.rs @@ -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 { + 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 { + 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 { + 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 { + axum::Json(serde_json::json!({ "status": "ok", "ts": chrono::Utc::now().to_rfc3339() })) +} diff --git a/src/routes/validate.rs b/src/routes/validate.rs new file mode 100644 index 0000000..feac8d2 --- /dev/null +++ b/src/routes/validate.rs @@ -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, +} + +#[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, + Json(body): Json, +) -> AppResult { + 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, + Json(body): Json, +) -> AppResult { + 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 })))) +} diff --git a/src/services/admin.rs b/src/services/admin.rs new file mode 100644 index 0000000..f4c614d --- /dev/null +++ b/src/services/admin.rs @@ -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> { + 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> { + 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 { + 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 { + 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 { + 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}"))) +} diff --git a/src/services/crypto.rs b/src/services/crypto.rs new file mode 100644 index 0000000..f816d88 --- /dev/null +++ b/src/services/crypto.rs @@ -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 = OnceLock::new(); +static PUBLIC_KEY: OnceLock = OnceLock::new(); + +fn load_encoding_key(path: &str) -> Result { + 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 { + 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 { + 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 { + 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::(token, key, &validation) + .map(|d| d.claims) + .context("Invalid or expired license token") +} + +pub fn issue_admin_jwt(cfg: &Config, claims: AdminClaims) -> Result { + 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 { + let key = DecodingKey::from_secret(cfg.jwt_secret.as_bytes()); + decode::(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()) +} diff --git a/src/services/license.rs b/src/services/license.rs new file mode 100644 index 0000000..8ea1c48 --- /dev/null +++ b/src/services/license.rs @@ -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> { + 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> { + 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, + notes: Option<&str>, +) -> AppResult { + 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 { + 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)> { + 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; +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..95d228c --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,3 @@ +pub mod admin; +pub mod crypto; +pub mod license; diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..a598116 --- /dev/null +++ b/src/state.rs @@ -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, +} + +impl AppState { + pub fn new(pool: MySqlPool, config: Config) -> Self { + Self { pool, config: Arc::new(config) } + } +}