mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
feat: license enforcement system — tiered feature gating
Backend: - LicenseService with RwLock<LicenseState> for runtime feature checking - Three-tier licensing: Community (free), Professional, Enterprise - Feature definitions with tier→feature mapping and route→feature mapping - LicenseGuard middleware: blocks API routes for unlicensed features (403) - License upload API (POST /api/system/license) with live validation - Features API (GET /api/system/features) for frontend UI gating - Grace period: 30-day warning → 14-day grace → community fallback - Periodic revalidation every 6 hours via background task - Removed #[cfg(feature = "license")] — license always compiled in - Ed25519 + base64 now mandatory deps (not optional) - Setup wizard accepts optional license_content field - license-generator: --tier flag and list-tiers subcommand - 24 new unit tests (features, service, tiers) Frontend: - LicenseProvider context with isFeatureEnabled() - LicenseBanner: status-aware warning banner - License management page (/license): tier display, feature matrix, upload - Setup wizard license step (optional, community mode on skip) - Sidebar feature gating with lock icons - 4-language i18n for all license strings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4ba660ee57
commit
73be8e5eaa
@ -3,6 +3,8 @@ name = "license-generator"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
ed25519-dalek = { version = "2", features = ["std", "rand_core"] }
|
||||
base64 = "0.22"
|
||||
@ -10,4 +12,4 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rand = "0.9"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
pnet = "0.36"
|
||||
pnet = "0.35"
|
||||
|
||||
@ -35,13 +35,18 @@ enum Commands {
|
||||
/// Expiry date (YYYY-MM-DD)
|
||||
#[arg(short, long)]
|
||||
expires: String,
|
||||
/// Comma-separated list of features
|
||||
/// Tier shortcut: community, professional, enterprise (overrides --features)
|
||||
#[arg(short, long)]
|
||||
tier: Option<String>,
|
||||
/// Comma-separated list of features (use --tier for shortcuts)
|
||||
#[arg(short, long, default_value = "")]
|
||||
features: String,
|
||||
/// Output license file path
|
||||
#[arg(short, long, default_value = "license.key")]
|
||||
output: String,
|
||||
},
|
||||
/// List available tiers and their features
|
||||
ListTiers,
|
||||
/// Verify a license file
|
||||
Verify {
|
||||
#[arg(short = 'k', long)]
|
||||
@ -86,10 +91,11 @@ fn main() {
|
||||
|
||||
match cli.command {
|
||||
Commands::Keygen { prefix } => keygen(&prefix),
|
||||
Commands::Issue { private_key, ingress, egress, expires, features, output } => {
|
||||
issue(&private_key, &ingress, &egress, &expires, &features, &output)
|
||||
Commands::Issue { private_key, ingress, egress, expires, tier, features, output } => {
|
||||
issue(&private_key, &ingress, &egress, &expires, tier.as_deref(), &features, &output)
|
||||
}
|
||||
Commands::Verify { public_key, license } => verify(&public_key, &license),
|
||||
Commands::ListTiers => list_tiers(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,7 +121,39 @@ fn keygen(prefix: &str) {
|
||||
println!(" {}", pub_hex);
|
||||
}
|
||||
|
||||
fn issue(private_key_path: &str, ingress: &str, egress: &str, expires: &str, features: &str, output: &str) {
|
||||
fn tier_features(tier: &str) -> Vec<String> {
|
||||
match tier {
|
||||
"enterprise" => vec![
|
||||
"dashboard", "acl", "acl_unlimited", "protocol_filter", "system_health",
|
||||
"geo_block", "dns_filter", "rate_limit", "soar", "soar_custom",
|
||||
"report", "email_alerts", "ml_detection", "telegram_alerts", "mcp_api",
|
||||
].into_iter().map(String::from).collect(),
|
||||
"professional" => vec![
|
||||
"dashboard", "acl", "acl_unlimited", "protocol_filter", "system_health",
|
||||
"geo_block", "dns_filter", "rate_limit", "soar", "report", "email_alerts",
|
||||
].into_iter().map(String::from).collect(),
|
||||
"community" => vec![
|
||||
"dashboard", "acl", "protocol_filter", "system_health",
|
||||
].into_iter().map(String::from).collect(),
|
||||
_ => {
|
||||
eprintln!("Unknown tier '{}'. Use: community, professional, enterprise", tier);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list_tiers() {
|
||||
for tier in ["community", "professional", "enterprise"] {
|
||||
let features = tier_features(tier);
|
||||
println!("{} ({} features):", tier.to_uppercase(), features.len());
|
||||
for f in &features {
|
||||
println!(" - {}", f);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
fn issue(private_key_path: &str, ingress: &str, egress: &str, expires: &str, tier: Option<&str>, features: &str, output: &str) {
|
||||
let ingress_mac = get_mac(ingress);
|
||||
let egress_mac = get_mac(egress);
|
||||
|
||||
@ -131,7 +169,9 @@ fn issue(private_key_path: &str, ingress: &str, egress: &str, expires: &str, fea
|
||||
let priv_array: [u8; 32] = priv_bytes.try_into().expect("Key must be 32 bytes");
|
||||
let signing_key = SigningKey::from_bytes(&priv_array);
|
||||
|
||||
let feature_list: Vec<String> = if features.is_empty() {
|
||||
let feature_list: Vec<String> = if let Some(tier_name) = tier {
|
||||
tier_features(tier_name)
|
||||
} else if features.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
features.split(',').map(|s| s.trim().to_string()).collect()
|
||||
|
||||
@ -69,12 +69,11 @@ argon2 = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
sd-notify = "0.4"
|
||||
rand = { workspace = true }
|
||||
ed25519-dalek = { workspace = true, optional = true }
|
||||
base64 = { workspace = true, optional = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
license = ["dep:ed25519-dalek", "dep:base64"]
|
||||
|
||||
[build-dependencies]
|
||||
cargo_metadata = { workspace = true }
|
||||
|
||||
@ -15,8 +15,6 @@ fn main() {
|
||||
}
|
||||
|
||||
fn embed_license_public_key() {
|
||||
let license_enabled = env::var("CARGO_FEATURE_LICENSE").is_ok();
|
||||
|
||||
let project_root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap())
|
||||
.parent()
|
||||
.unwrap()
|
||||
@ -31,14 +29,8 @@ fn embed_license_public_key() {
|
||||
.trim()
|
||||
.to_string();
|
||||
println!("cargo:rustc-env=LICENSE_PUBLIC_KEY={}", key_hex);
|
||||
} else if license_enabled {
|
||||
panic!(
|
||||
"license feature enabled but license_pub.key not found at {}.\n\
|
||||
Generate it with: cd license-generator && cargo run -- keygen\n\
|
||||
Then copy license_pub.key to the repo root.",
|
||||
key_path.display()
|
||||
);
|
||||
} else {
|
||||
// No key file — license validation will treat all licenses as unlicensed (community mode)
|
||||
println!("cargo:rustc-env=LICENSE_PUBLIC_KEY=DISABLED");
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,6 +74,8 @@ struct SetupRequest {
|
||||
/// Telegram config (optional)
|
||||
telegram_bot_token: Option<String>,
|
||||
telegram_chat_id: Option<String>,
|
||||
/// License content (optional — skip for community mode)
|
||||
license_content: Option<String>,
|
||||
}
|
||||
|
||||
/// Validate interface name: only alphanumeric, dots, underscores, hyphens allowed.
|
||||
@ -147,6 +149,17 @@ async fn complete_setup(
|
||||
}
|
||||
}
|
||||
|
||||
// Save license content if provided (optional — community mode if skipped)
|
||||
if let Some(ref license_content) = body.license_content {
|
||||
let license_content = license_content.trim();
|
||||
if !license_content.is_empty() {
|
||||
// Write license file to the default path
|
||||
if let Err(e) = std::fs::write("license.key", license_content) {
|
||||
log!(SystemError::SetupLicenseWriteFailed(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark setup as complete
|
||||
if let Err(e) = db.set_setting("setup_complete", "true") {
|
||||
log!(SystemError::SetupCompleteFlagFailed(e));
|
||||
|
||||
@ -2,6 +2,7 @@ use actix_web::{web, HttpResponse, Responder, Scope};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::core::config_service::ConfigService;
|
||||
use crate::core::license::service::LicenseService;
|
||||
use crate::infrastructure::communication_manager::CommunicationManager;
|
||||
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
|
||||
use crate::interface::communication::query_types::GetEnforceModeQuery;
|
||||
@ -14,19 +15,22 @@ struct EnforceModeRequest {
|
||||
mode: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UploadLicenseRequest {
|
||||
license_content: String,
|
||||
}
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
let scope = web::scope("/system")
|
||||
web::scope("/system")
|
||||
.route("/boot-time", web::get().to(get_boot_time))
|
||||
.route("/enforce-mode", web::get().to(get_enforce_mode))
|
||||
.route("/enforce-mode", web::put().to(set_enforce_mode))
|
||||
.route("/xdp-mode", web::get().to(get_xdp_mode))
|
||||
.route("/config", web::get().to(get_config))
|
||||
.route("/config", web::put().to(update_config));
|
||||
|
||||
#[cfg(feature = "license")]
|
||||
let scope = scope.route("/license", web::get().to(get_license_info));
|
||||
|
||||
scope
|
||||
.route("/config", web::put().to(update_config))
|
||||
.route("/license", web::get().to(get_license_info))
|
||||
.route("/license", web::post().to(upload_license))
|
||||
.route("/features", web::get().to(get_features))
|
||||
}
|
||||
|
||||
async fn get_boot_time() -> impl Responder {
|
||||
@ -91,7 +95,20 @@ async fn update_config(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "license")]
|
||||
async fn get_license_info(license_info: web::Data<crate::core::license::LicenseInfo>) -> impl Responder {
|
||||
HttpResponse::Ok().json(license_info.get_ref())
|
||||
async fn get_license_info(license_service: web::Data<LicenseService>) -> impl Responder {
|
||||
HttpResponse::Ok().json(license_service.license_response().await)
|
||||
}
|
||||
|
||||
async fn upload_license(
|
||||
body: web::Json<UploadLicenseRequest>,
|
||||
license_service: web::Data<LicenseService>,
|
||||
) -> impl Responder {
|
||||
match license_service.upload_license(&body.license_content).await {
|
||||
Ok(response) => HttpResponse::Ok().json(response),
|
||||
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_features(license_service: web::Data<LicenseService>) -> impl Responder {
|
||||
HttpResponse::Ok().json(license_service.features_for_frontend().await)
|
||||
}
|
||||
|
||||
123
net-guardia/src/core/auth/license_guard.rs
Normal file
123
net-guardia/src/core/auth/license_guard.rs
Normal file
@ -0,0 +1,123 @@
|
||||
use std::future::{ready, Future, Ready};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::body::EitherBody;
|
||||
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
|
||||
use actix_web::{web, Error as ActixError, HttpResponse};
|
||||
use macros::log;
|
||||
|
||||
use crate::core::license::service::LicenseService;
|
||||
use crate::core::license::features::{feature_route_map, features_for_tier};
|
||||
use crate::model::log::system::SystemLog;
|
||||
|
||||
/// Middleware that checks license features against the request path.
|
||||
/// Unlicensed features get a 403 with information about the required tier.
|
||||
pub struct LicenseGuard;
|
||||
|
||||
impl<S, B> Transform<S, ServiceRequest> for LicenseGuard
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<EitherBody<B>>;
|
||||
type Error = ActixError;
|
||||
type Transform = LicenseGuardService<S>;
|
||||
type InitError = ();
|
||||
type Future = Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
ready(Ok(LicenseGuardService {
|
||||
service: std::rc::Rc::new(service),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LicenseGuardService<S> {
|
||||
service: std::rc::Rc<S>,
|
||||
}
|
||||
|
||||
impl<S, B> Service<ServiceRequest> for LicenseGuardService<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<EitherBody<B>>;
|
||||
type Error = ActixError;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
|
||||
|
||||
fn poll_ready(
|
||||
&self,
|
||||
ctx: &mut core::task::Context<'_>,
|
||||
) -> std::task::Poll<Result<(), Self::Error>> {
|
||||
self.service.poll_ready(ctx)
|
||||
}
|
||||
|
||||
fn call(&self, req: ServiceRequest) -> Self::Future {
|
||||
let service = std::rc::Rc::clone(&self.service);
|
||||
|
||||
Box::pin(async move {
|
||||
let path = req.path().to_string();
|
||||
|
||||
// Check if this path requires a licensed feature
|
||||
let required_feature = {
|
||||
let mut found = None;
|
||||
for (prefix, feature) in feature_route_map() {
|
||||
if path.starts_with(prefix) {
|
||||
found = Some(feature);
|
||||
break;
|
||||
}
|
||||
}
|
||||
found
|
||||
};
|
||||
|
||||
// If no feature required for this path, pass through
|
||||
let Some(feature) = required_feature else {
|
||||
let res = service.call(req).await?.map_into_left_body();
|
||||
return Ok(res);
|
||||
};
|
||||
|
||||
// Check if the feature is enabled in the license
|
||||
let license_service = req.app_data::<web::Data<Arc<LicenseService>>>();
|
||||
let is_enabled = match license_service {
|
||||
Some(svc) => svc.is_feature_enabled(feature).await,
|
||||
None => false, // No license service = community mode
|
||||
};
|
||||
|
||||
if is_enabled {
|
||||
let res = service.call(req).await?.map_into_left_body();
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
// Feature not licensed — determine required tier for the error message
|
||||
let required_tier = required_tier_for_feature(feature);
|
||||
log!(SystemLog::LicenseFeatureBlocked(
|
||||
feature.to_string(),
|
||||
path.clone(),
|
||||
required_tier.to_string(),
|
||||
));
|
||||
|
||||
let resp = HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({
|
||||
"error": format!("Feature '{}' requires {} license", feature, required_tier),
|
||||
"feature": feature,
|
||||
"required_tier": required_tier,
|
||||
}));
|
||||
Ok(req.into_response(resp).map_into_right_body())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine the minimum tier that includes a given feature.
|
||||
fn required_tier_for_feature(feature: &str) -> &'static str {
|
||||
let professional_set = features_for_tier("professional");
|
||||
let community_set = features_for_tier("community");
|
||||
|
||||
if community_set.contains(feature) {
|
||||
"community"
|
||||
} else if professional_set.contains(feature) {
|
||||
"professional"
|
||||
} else {
|
||||
"enterprise"
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
pub mod extractor;
|
||||
pub mod jwt;
|
||||
pub mod license_guard;
|
||||
pub mod middleware;
|
||||
pub mod password;
|
||||
pub mod setup_guard;
|
||||
|
||||
197
net-guardia/src/core/license/features.rs
Normal file
197
net-guardia/src/core/license/features.rs
Normal file
@ -0,0 +1,197 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
// --- Tier feature definitions ---
|
||||
|
||||
pub const COMMUNITY_FEATURES: &[&str] = &[
|
||||
"dashboard",
|
||||
"acl",
|
||||
"protocol_filter",
|
||||
"system_health",
|
||||
];
|
||||
|
||||
pub const PROFESSIONAL_FEATURES: &[&str] = &[
|
||||
"dashboard",
|
||||
"acl",
|
||||
"acl_unlimited",
|
||||
"protocol_filter",
|
||||
"system_health",
|
||||
"geo_block",
|
||||
"dns_filter",
|
||||
"rate_limit",
|
||||
"soar",
|
||||
"report",
|
||||
"email_alerts",
|
||||
];
|
||||
|
||||
pub const ENTERPRISE_FEATURES: &[&str] = &[
|
||||
"dashboard",
|
||||
"acl",
|
||||
"acl_unlimited",
|
||||
"protocol_filter",
|
||||
"system_health",
|
||||
"geo_block",
|
||||
"dns_filter",
|
||||
"rate_limit",
|
||||
"soar",
|
||||
"soar_custom",
|
||||
"report",
|
||||
"email_alerts",
|
||||
"ml_detection",
|
||||
"telegram_alerts",
|
||||
"mcp_api",
|
||||
];
|
||||
|
||||
/// All known feature names (superset of all tiers).
|
||||
pub const ALL_FEATURES: &[&str] = ENTERPRISE_FEATURES;
|
||||
|
||||
/// Expand a tier name to its feature set.
|
||||
pub fn features_for_tier(tier: &str) -> HashSet<String> {
|
||||
let slice = match tier {
|
||||
"enterprise" => ENTERPRISE_FEATURES,
|
||||
"professional" => PROFESSIONAL_FEATURES,
|
||||
_ => COMMUNITY_FEATURES,
|
||||
};
|
||||
slice.iter().map(|s| (*s).to_string()).collect()
|
||||
}
|
||||
|
||||
/// Given a license's feature list, resolve the effective feature set.
|
||||
///
|
||||
/// - If the list contains `"all"`, grant everything (enterprise).
|
||||
/// - If the list contains a tier name (`"enterprise"`, `"professional"`), expand it.
|
||||
/// - Otherwise treat each entry as an individual feature grant and combine with community baseline.
|
||||
pub fn resolve_features(license_features: &[String]) -> HashSet<String> {
|
||||
// Special: "all" grants everything
|
||||
if license_features.iter().any(|f| f == "all") {
|
||||
return features_for_tier("enterprise");
|
||||
}
|
||||
|
||||
// Check for tier shortcut
|
||||
for f in license_features {
|
||||
match f.as_str() {
|
||||
"enterprise" => return features_for_tier("enterprise"),
|
||||
"professional" => return features_for_tier("professional"),
|
||||
"community" => return features_for_tier("community"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Individual features: start from community baseline + add listed features
|
||||
let mut features = features_for_tier("community");
|
||||
for f in license_features {
|
||||
if ALL_FEATURES.contains(&f.as_str()) {
|
||||
features.insert(f.clone());
|
||||
}
|
||||
}
|
||||
features
|
||||
}
|
||||
|
||||
/// Infer the marketing tier name from a resolved feature set.
|
||||
pub fn tier_from_features(features: &HashSet<String>) -> &'static str {
|
||||
let enterprise_set = features_for_tier("enterprise");
|
||||
let professional_set = features_for_tier("professional");
|
||||
|
||||
if enterprise_set.is_subset(features) {
|
||||
"enterprise"
|
||||
} else if professional_set.is_subset(features) {
|
||||
"professional"
|
||||
} else {
|
||||
"community"
|
||||
}
|
||||
}
|
||||
|
||||
/// Map of HTTP route prefixes to required feature names.
|
||||
/// Used by LicenseGuard middleware to enforce access.
|
||||
pub fn feature_route_map() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
("/api/acl/geo", "geo_block"),
|
||||
("/api/filter/dns", "dns_filter"),
|
||||
("/api/rate-limit", "rate_limit"),
|
||||
("/api/ml", "ml_detection"),
|
||||
("/api/soar", "soar"),
|
||||
("/api/report", "report"),
|
||||
("/api/notifications/telegram", "telegram_alerts"),
|
||||
("/api/notifications/smtp", "email_alerts"),
|
||||
("/api/mcp-keys", "mcp_api"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Build a map of all features → enabled/disabled for frontend display.
|
||||
pub fn all_features_map(enabled: &HashSet<String>) -> HashMap<String, bool> {
|
||||
ALL_FEATURES
|
||||
.iter()
|
||||
.map(|f| ((*f).to_string(), enabled.contains(*f)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn community_is_subset_of_professional() {
|
||||
let community = features_for_tier("community");
|
||||
let professional = features_for_tier("professional");
|
||||
assert!(community.is_subset(&professional));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn professional_is_subset_of_enterprise() {
|
||||
let professional = features_for_tier("professional");
|
||||
let enterprise = features_for_tier("enterprise");
|
||||
assert!(professional.is_subset(&enterprise));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_all_grants_enterprise() {
|
||||
let features = resolve_features(&["all".to_string()]);
|
||||
assert_eq!(tier_from_features(&features), "enterprise");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_tier_shortcut() {
|
||||
let features = resolve_features(&["professional".to_string()]);
|
||||
assert_eq!(tier_from_features(&features), "professional");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_individual_features() {
|
||||
let features = resolve_features(&[
|
||||
"geo_block".to_string(),
|
||||
"ml_detection".to_string(),
|
||||
]);
|
||||
assert!(features.contains("geo_block"));
|
||||
assert!(features.contains("ml_detection"));
|
||||
// Community baseline included
|
||||
assert!(features.contains("dashboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_detection_community() {
|
||||
let features = features_for_tier("community");
|
||||
assert_eq!(tier_from_features(&features), "community");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_features_is_community() {
|
||||
let features = resolve_features(&[]);
|
||||
assert_eq!(tier_from_features(&features), "community");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_features_ignored() {
|
||||
let features = resolve_features(&["nonexistent_feature".to_string()]);
|
||||
assert!(!features.contains("nonexistent_feature"));
|
||||
// Still has community baseline
|
||||
assert!(features.contains("dashboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_route_map_not_empty() {
|
||||
let map = feature_route_map();
|
||||
assert!(!map.is_empty());
|
||||
// All mapped features should be known
|
||||
for (_, feature) in &map {
|
||||
assert!(ALL_FEATURES.contains(feature), "Unknown feature in route map: {}", feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,3 @@
|
||||
pub mod features;
|
||||
pub mod service;
|
||||
pub mod validator;
|
||||
|
||||
pub use crate::model::license::LicenseInfo;
|
||||
|
||||
253
net-guardia/src/core/license/service.rs
Normal file
253
net-guardia/src/core/license/service.rs
Normal file
@ -0,0 +1,253 @@
|
||||
use macros::log;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::model::error::Error;
|
||||
use crate::model::error::license::LicenseError;
|
||||
use crate::model::license::{
|
||||
FeaturesResponse, LicenseInfo, LicensePayloadPublic, LicenseResponse, LicenseState, LicenseStatus,
|
||||
};
|
||||
use crate::model::log::system::SystemLog;
|
||||
|
||||
use super::features::{all_features_map, features_for_tier, resolve_features, tier_from_features};
|
||||
use super::validator::{validate_license, validate_license_content};
|
||||
|
||||
/// Central license service holding runtime state.
|
||||
/// Thread-safe via `Arc<LicenseService>`, interior mutability via `RwLock`.
|
||||
pub struct LicenseService {
|
||||
state: RwLock<LicenseState>,
|
||||
/// Cached config for revalidation.
|
||||
license_path: String,
|
||||
ingress_ifname: String,
|
||||
egress_ifname: String,
|
||||
}
|
||||
|
||||
impl LicenseService {
|
||||
/// Create a new LicenseService by validating the license at startup.
|
||||
pub fn new(license_path: &str, ingress_ifname: &str, egress_ifname: &str) -> Result<Self, Error> {
|
||||
let info = validate_license(license_path, ingress_ifname, egress_ifname)?;
|
||||
let state = Self::compute_state(&info);
|
||||
|
||||
log!(SystemLog::LicenseTierResolved(state.tier.clone(), state.enabled_features.len()));
|
||||
|
||||
Ok(Self {
|
||||
state: RwLock::new(state),
|
||||
license_path: license_path.to_string(),
|
||||
ingress_ifname: ingress_ifname.to_string(),
|
||||
egress_ifname: egress_ifname.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a specific feature is enabled.
|
||||
pub async fn is_feature_enabled(&self, feature: &str) -> bool {
|
||||
let state = self.state.read().await;
|
||||
state.enabled_features.contains(feature)
|
||||
}
|
||||
|
||||
/// Build features response for frontend API.
|
||||
pub async fn features_for_frontend(&self) -> FeaturesResponse {
|
||||
let state = self.state.read().await;
|
||||
let mut enabled: Vec<String> = state.enabled_features.iter().cloned().collect();
|
||||
enabled.sort();
|
||||
FeaturesResponse {
|
||||
tier: state.tier.clone(),
|
||||
status: state.status.clone(),
|
||||
days_remaining: state.info.days_remaining,
|
||||
enabled,
|
||||
all_features: all_features_map(&state.enabled_features),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build license response for GET /api/system/license.
|
||||
pub async fn license_response(&self) -> LicenseResponse {
|
||||
let state = self.state.read().await;
|
||||
let mut enabled: Vec<String> = state.enabled_features.iter().cloned().collect();
|
||||
enabled.sort();
|
||||
let payload_public = state.info.payload.as_ref().map(|p| LicensePayloadPublic {
|
||||
expires: p.expires.clone(),
|
||||
features: p.features.clone(),
|
||||
});
|
||||
LicenseResponse {
|
||||
valid: state.info.valid,
|
||||
status: state.status.clone(),
|
||||
tier: state.tier.clone(),
|
||||
enabled_features: enabled,
|
||||
days_remaining: state.info.days_remaining,
|
||||
payload: payload_public,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the license file and update state. Called by periodic task.
|
||||
/// Returns the new status if it changed from the old status.
|
||||
pub async fn revalidate(&self) -> Option<LicenseStatus> {
|
||||
let new_info = match validate_license(&self.license_path, &self.ingress_ifname, &self.egress_ifname) {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
log!(SystemLog::LicenseRevalidationFailed(e.to_string()));
|
||||
LicenseInfo::unlicensed()
|
||||
}
|
||||
};
|
||||
|
||||
let new_state = Self::compute_state(&new_info);
|
||||
let mut state = self.state.write().await;
|
||||
let old_status = state.status.clone();
|
||||
let new_status = new_state.status.clone();
|
||||
*state = new_state;
|
||||
|
||||
if old_status != new_status {
|
||||
log!(SystemLog::LicenseStatusChanged(
|
||||
format!("{:?}", old_status),
|
||||
format!("{:?}", new_status),
|
||||
));
|
||||
Some(new_status)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a new license, validate it, write to disk, and update state.
|
||||
pub async fn upload_license(&self, content: &str) -> Result<LicenseResponse, Error> {
|
||||
// Validate the content
|
||||
let info = validate_license_content(content, &self.ingress_ifname, &self.egress_ifname)?;
|
||||
|
||||
if !info.valid {
|
||||
return Err(LicenseError::ValidationFailed {
|
||||
reason: "License validation failed".to_string(),
|
||||
}.into());
|
||||
}
|
||||
|
||||
// Write to disk
|
||||
std::fs::write(&self.license_path, content.trim())
|
||||
.map_err(|e| LicenseError::UploadFailed {
|
||||
reason: format!("Failed to write license file: {}", e),
|
||||
})?;
|
||||
|
||||
// Update runtime state
|
||||
let new_state = Self::compute_state(&info);
|
||||
log!(SystemLog::LicenseUploaded(new_state.tier.clone()));
|
||||
|
||||
let response = {
|
||||
let mut enabled: Vec<String> = new_state.enabled_features.iter().cloned().collect();
|
||||
enabled.sort();
|
||||
let payload_public = info.payload.as_ref().map(|p| LicensePayloadPublic {
|
||||
expires: p.expires.clone(),
|
||||
features: p.features.clone(),
|
||||
});
|
||||
LicenseResponse {
|
||||
valid: info.valid,
|
||||
status: new_state.status.clone(),
|
||||
tier: new_state.tier.clone(),
|
||||
enabled_features: enabled,
|
||||
days_remaining: info.days_remaining,
|
||||
payload: payload_public,
|
||||
}
|
||||
};
|
||||
|
||||
*self.state.write().await = new_state;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Compute LicenseState from LicenseInfo.
|
||||
fn compute_state(info: &LicenseInfo) -> LicenseState {
|
||||
let (status, enabled_features, tier) = if info.valid {
|
||||
let status = LicenseStatus::from_days_remaining(info.days_remaining);
|
||||
let features = if let Some(ref payload) = info.payload {
|
||||
resolve_features(&payload.features)
|
||||
} else {
|
||||
features_for_tier("community")
|
||||
};
|
||||
// If features are no longer active (expired past grace), fall back to community
|
||||
let effective_features = if status.features_active() {
|
||||
features.clone()
|
||||
} else {
|
||||
features_for_tier("community")
|
||||
};
|
||||
let tier = tier_from_features(&effective_features).to_string();
|
||||
(status, effective_features, tier)
|
||||
} else {
|
||||
(
|
||||
LicenseStatus::Unlicensed,
|
||||
features_for_tier("community"),
|
||||
"community".to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
LicenseState {
|
||||
info: info.clone(),
|
||||
status,
|
||||
enabled_features,
|
||||
tier,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compute_state_unlicensed() {
|
||||
let info = LicenseInfo::unlicensed();
|
||||
let state = LicenseService::compute_state(&info);
|
||||
assert_eq!(state.status, LicenseStatus::Unlicensed);
|
||||
assert_eq!(state.tier, "community");
|
||||
assert!(state.enabled_features.contains("dashboard"));
|
||||
assert!(!state.enabled_features.contains("ml_detection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_state_active_professional() {
|
||||
let info = LicenseInfo {
|
||||
payload: Some(crate::model::license::LicensePayload {
|
||||
ingress_mac: String::new(),
|
||||
egress_mac: String::new(),
|
||||
expires: String::new(),
|
||||
features: vec!["professional".to_string()],
|
||||
}),
|
||||
valid: true,
|
||||
days_remaining: 100,
|
||||
};
|
||||
let state = LicenseService::compute_state(&info);
|
||||
assert_eq!(state.status, LicenseStatus::Active);
|
||||
assert_eq!(state.tier, "professional");
|
||||
assert!(state.enabled_features.contains("geo_block"));
|
||||
assert!(!state.enabled_features.contains("ml_detection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_state_expired_past_grace() {
|
||||
let info = LicenseInfo {
|
||||
payload: Some(crate::model::license::LicensePayload {
|
||||
ingress_mac: String::new(),
|
||||
egress_mac: String::new(),
|
||||
expires: String::new(),
|
||||
features: vec!["enterprise".to_string()],
|
||||
}),
|
||||
valid: true,
|
||||
days_remaining: -20,
|
||||
};
|
||||
let state = LicenseService::compute_state(&info);
|
||||
assert_eq!(state.status, LicenseStatus::Expired);
|
||||
assert_eq!(state.tier, "community");
|
||||
// Enterprise features should NOT be active
|
||||
assert!(!state.enabled_features.contains("ml_detection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_state_grace_period() {
|
||||
let info = LicenseInfo {
|
||||
payload: Some(crate::model::license::LicensePayload {
|
||||
ingress_mac: String::new(),
|
||||
egress_mac: String::new(),
|
||||
expires: String::new(),
|
||||
features: vec!["enterprise".to_string()],
|
||||
}),
|
||||
valid: true,
|
||||
days_remaining: -5,
|
||||
};
|
||||
let state = LicenseService::compute_state(&info);
|
||||
assert_eq!(state.status, LicenseStatus::GracePeriod(9));
|
||||
// Features still active during grace
|
||||
assert!(state.enabled_features.contains("ml_detection"));
|
||||
}
|
||||
|
||||
}
|
||||
@ -15,12 +15,12 @@ use crate::model::log::system::SystemLog;
|
||||
/// Then place license_pub.key in the repo root.
|
||||
const PUBLIC_KEY_HEX: &str = env!("LICENSE_PUBLIC_KEY");
|
||||
|
||||
/// Validate a license file from disk. Returns unlicensed (not error) if file missing.
|
||||
pub fn validate_license(license_path: &str, ingress_ifname: &str, egress_ifname: &str) -> Result<LicenseInfo, Error> {
|
||||
// Guard against builds where the license feature was not configured
|
||||
// Guard against builds where the public key was not embedded
|
||||
if PUBLIC_KEY_HEX == "DISABLED" {
|
||||
return Err(LicenseError::ValidationFailed {
|
||||
reason: "License validation not configured".to_string(),
|
||||
}.into());
|
||||
log!(SystemLog::LicenseNotConfigured);
|
||||
return Ok(LicenseInfo::unlicensed());
|
||||
}
|
||||
|
||||
// If path is empty, license is optional — return unlicensed
|
||||
@ -38,10 +38,19 @@ pub fn validate_license(license_path: &str, ingress_ifname: &str, egress_ifname:
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.map_err(|_| LicenseError::FileNotFound { path: license_path.to_string() })?;
|
||||
|
||||
let contents = contents.trim();
|
||||
validate_license_content(contents.trim(), ingress_ifname, egress_ifname)
|
||||
}
|
||||
|
||||
/// Validate a license from raw content string. Used by both file-based validation and upload API.
|
||||
pub fn validate_license_content(content: &str, ingress_ifname: &str, egress_ifname: &str) -> Result<LicenseInfo, Error> {
|
||||
if PUBLIC_KEY_HEX == "DISABLED" {
|
||||
return Err(LicenseError::ValidationFailed {
|
||||
reason: "License validation not configured — no public key embedded".to_string(),
|
||||
}.into());
|
||||
}
|
||||
|
||||
// Format: base64(json_payload).base64(ed25519_signature)
|
||||
let parts: Vec<&str> = contents.splitn(2, '.').collect();
|
||||
let parts: Vec<&str> = content.splitn(2, '.').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(LicenseError::ValidationFailed {
|
||||
reason: "Invalid license format: expected <payload>.<signature>".to_string(),
|
||||
@ -113,17 +122,13 @@ pub fn validate_license(license_path: &str, ingress_ifname: &str, egress_ifname:
|
||||
}.into());
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
// Check expiry — allow expired licenses (grace period handled by LicenseService)
|
||||
let today = chrono_free_today();
|
||||
let days_remaining = days_until(&payload.expires, &today)
|
||||
.map_err(|e| LicenseError::ValidationFailed {
|
||||
reason: format!("Invalid expiry date: {}", e),
|
||||
})?;
|
||||
|
||||
if days_remaining < 0 {
|
||||
return Err(LicenseError::Expired.into());
|
||||
}
|
||||
|
||||
log!(SystemLog::LicenseValid(
|
||||
payload.ingress_mac.clone(),
|
||||
payload.egress_mac.clone(),
|
||||
@ -152,7 +157,7 @@ fn get_interface_mac(ifname: &str) -> Option<String> {
|
||||
|
||||
/// Simple hex decoder without external dependency.
|
||||
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
|
||||
if hex.len() % 2 != 0 {
|
||||
if !hex.len().is_multiple_of(2) {
|
||||
return Err("Odd-length hex string".to_string());
|
||||
}
|
||||
(0..hex.len())
|
||||
@ -163,7 +168,6 @@ fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
|
||||
|
||||
/// Parse YYYY-MM-DD date and return days until expiry (no chrono dependency).
|
||||
fn chrono_free_today() -> (i32, u32, u32) {
|
||||
// Use UNIX_EPOCH to get today's date
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
@ -249,7 +253,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_epoch_roundtrip() {
|
||||
// Test several dates
|
||||
let dates = vec![
|
||||
(2026, 3, 21),
|
||||
(2000, 1, 1),
|
||||
@ -288,14 +291,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_interface_mac_path_traversal() {
|
||||
// Should reject path traversal attempts
|
||||
assert!(get_interface_mac("../etc/passwd").is_none());
|
||||
assert!(get_interface_mac("eth0/../..").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_license_format_invalid() {
|
||||
// Test with invalid license content (no file, just the parsing logic)
|
||||
let bad_formats = vec!["", "nodot", "too.many.dots"];
|
||||
for fmt in bad_formats {
|
||||
let parts: Vec<&str> = fmt.splitn(2, '.').collect();
|
||||
|
||||
@ -4,7 +4,6 @@ pub mod config_service;
|
||||
pub mod dns_filter_service;
|
||||
pub mod email;
|
||||
pub mod ebpf;
|
||||
#[cfg(feature = "license")]
|
||||
pub mod license;
|
||||
pub mod ml;
|
||||
pub mod notification_service;
|
||||
|
||||
@ -17,8 +17,7 @@ use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::app_services::AppServices;
|
||||
use crate::infrastructure::communication_manager::CommunicationManager;
|
||||
use crate::core::ml::config_loader::InferenceConfig;
|
||||
#[cfg(feature = "license")]
|
||||
use crate::core::license::LicenseInfo;
|
||||
use crate::core::license::service::LicenseService;
|
||||
use crate::infrastructure::http_server::HttpServerParams;
|
||||
use crate::infrastructure::service_factory::ServiceFactory;
|
||||
use crate::core::email::scheduler::ReportScheduler;
|
||||
@ -42,8 +41,7 @@ pub struct System {
|
||||
pub db: Arc<Database>,
|
||||
pub jwt_service: Arc<JwtService>,
|
||||
pub comm: Arc<CommunicationManager>,
|
||||
#[cfg(feature = "license")]
|
||||
pub license_info: Arc<LicenseInfo>,
|
||||
pub license_service: Arc<LicenseService>,
|
||||
pub soar_engine: Arc<SoarEngine>,
|
||||
pub ttl_scheduler: Option<TtlScheduler>,
|
||||
pub report_scheduler: Option<ReportScheduler>,
|
||||
@ -70,8 +68,7 @@ impl System {
|
||||
db: state.db,
|
||||
jwt_service: state.jwt_service,
|
||||
comm: state.comm,
|
||||
#[cfg(feature = "license")]
|
||||
license_info: state.license_info,
|
||||
license_service: state.license_service,
|
||||
soar_engine: state.soar_engine,
|
||||
ttl_scheduler: Some(state.ttl_scheduler),
|
||||
report_scheduler: Some(state.report_scheduler),
|
||||
@ -138,6 +135,17 @@ impl System {
|
||||
Self::bridge_ml_to_soar(ml_alert_rx, comm_for_bridge).await;
|
||||
});
|
||||
|
||||
// Start periodic license revalidation (every 6 hours)
|
||||
let license_service_for_revalidation = self.license_service.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(6 * 3600));
|
||||
interval.tick().await; // skip immediate first tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
license_service_for_revalidation.revalidate().await;
|
||||
}
|
||||
});
|
||||
|
||||
// Start HTTP server in background (!Send, use actix::spawn)
|
||||
let setup_flag = Arc::new(std::sync::atomic::AtomicBool::new(true));
|
||||
let ready_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
@ -150,8 +158,7 @@ impl System {
|
||||
db: self.db.clone(),
|
||||
jwt_service: self.jwt_service.clone(),
|
||||
comm: self.comm.clone(),
|
||||
#[cfg(feature = "license")]
|
||||
license_info: self.license_info.clone(),
|
||||
license_service: self.license_service.clone(),
|
||||
setup_complete: setup_flag,
|
||||
ready: ready_flag,
|
||||
acl_service: self.acl_service.clone(),
|
||||
|
||||
@ -16,8 +16,7 @@ use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::app_services::AppServices;
|
||||
use crate::infrastructure::communication_manager::CommunicationManager;
|
||||
use crate::core::ml::config_loader::InferenceConfig;
|
||||
#[cfg(feature = "license")]
|
||||
use crate::core::license::LicenseInfo;
|
||||
use crate::core::license::service::LicenseService;
|
||||
use macros::log;
|
||||
use crate::model::error::http::HttpError;
|
||||
use crate::model::error::Error;
|
||||
@ -39,8 +38,7 @@ pub struct HttpServerParams {
|
||||
pub db: Arc<Database>,
|
||||
pub jwt_service: Arc<JwtService>,
|
||||
pub comm: Arc<CommunicationManager>,
|
||||
#[cfg(feature = "license")]
|
||||
pub license_info: Arc<LicenseInfo>,
|
||||
pub license_service: Arc<LicenseService>,
|
||||
pub setup_complete: SetupCompleteFlag,
|
||||
pub ready: ReadyFlag,
|
||||
pub acl_service: Arc<AclService>,
|
||||
@ -169,8 +167,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
|
||||
let db = params.db;
|
||||
let jwt_service = params.jwt_service;
|
||||
let comm = params.comm;
|
||||
#[cfg(feature = "license")]
|
||||
let license_info = params.license_info;
|
||||
let license_service = params.license_service;
|
||||
let setup_complete = params.setup_complete;
|
||||
let ready = params.ready;
|
||||
let acl_service = params.acl_service;
|
||||
@ -208,11 +205,11 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> {
|
||||
.app_data(web::Data::from(notification_service.clone()))
|
||||
.app_data(web::Data::from(playbook_service.clone()))
|
||||
.app_data(web::Data::from(rate_limit_service.clone()));
|
||||
#[cfg(feature = "license")]
|
||||
let app = app.app_data(web::Data::from(license_info.clone()));
|
||||
let app = app.app_data(web::Data::from(license_service.clone()));
|
||||
app.wrap(SetupGuard)
|
||||
.service(
|
||||
web::scope("/api")
|
||||
.wrap(crate::core::auth::license_guard::LicenseGuard)
|
||||
.wrap(crate::core::auth::middleware::AuthMiddleware)
|
||||
.service(auth::initialize())
|
||||
.service(acl::initialize())
|
||||
|
||||
@ -33,10 +33,7 @@ use crate::core::soar::engine::SoarEngine;
|
||||
use crate::core::soar::scheduler::TtlScheduler;
|
||||
use crate::core::email::scheduler::ReportScheduler;
|
||||
use crate::infrastructure::geoip::GeoIpService;
|
||||
#[cfg(feature = "license")]
|
||||
use crate::core::license::LicenseInfo;
|
||||
#[cfg(feature = "license")]
|
||||
use crate::core::license::validator::validate_license;
|
||||
use crate::core::license::service::LicenseService;
|
||||
use crate::core::ml::config_loader::InferenceConfig;
|
||||
use crate::model::direction::FlowDirection;
|
||||
use crate::model::error::ebpf::EbpfError;
|
||||
@ -56,8 +53,7 @@ pub struct AppState {
|
||||
pub db: Arc<Database>,
|
||||
pub jwt_service: Arc<JwtService>,
|
||||
pub comm: Arc<CommunicationManager>,
|
||||
#[cfg(feature = "license")]
|
||||
pub license_info: Arc<LicenseInfo>,
|
||||
pub license_service: Arc<LicenseService>,
|
||||
pub soar_engine: Arc<SoarEngine>,
|
||||
pub ttl_scheduler: TtlScheduler,
|
||||
pub report_scheduler: ReportScheduler,
|
||||
@ -96,8 +92,7 @@ impl ServiceFactory {
|
||||
let mut ingress_ebpf = Self::load_ebpf("ingress")?;
|
||||
let mut egress_ebpf = Self::load_ebpf("egress")?;
|
||||
|
||||
#[cfg(feature = "license")]
|
||||
let license_info = Arc::new(validate_license(
|
||||
let license_service = Arc::new(LicenseService::new(
|
||||
&app_config.misc.license_file,
|
||||
&app_config.network.ingress_ifname,
|
||||
&app_config.network.egress_ifname,
|
||||
@ -226,8 +221,7 @@ impl ServiceFactory {
|
||||
db,
|
||||
jwt_service,
|
||||
comm,
|
||||
#[cfg(feature = "license")]
|
||||
license_info,
|
||||
license_service,
|
||||
soar_engine,
|
||||
ttl_scheduler,
|
||||
report_scheduler,
|
||||
|
||||
@ -6,14 +6,24 @@ traceable! {
|
||||
#[error("License file not found: {path}")]
|
||||
FileNotFound { path: String } => tracing::Level::WARN,
|
||||
|
||||
#[no_source]
|
||||
#[error("Invalid license signature")]
|
||||
InvalidSignature => tracing::Level::ERROR,
|
||||
|
||||
#[no_source]
|
||||
#[error("License has expired")]
|
||||
Expired => tracing::Level::WARN,
|
||||
|
||||
#[no_source]
|
||||
#[error("License validation failed: {reason}")]
|
||||
ValidationFailed { reason: String } => tracing::Level::ERROR,
|
||||
|
||||
#[no_source]
|
||||
#[error("Feature not licensed: {feature} (requires {required_tier})")]
|
||||
FeatureNotLicensed { feature: String, required_tier: String } => tracing::Level::WARN,
|
||||
|
||||
#[no_source]
|
||||
#[error("License upload failed: {reason}")]
|
||||
UploadFailed { reason: String } => tracing::Level::ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ pub mod database;
|
||||
pub mod ebpf;
|
||||
pub mod http;
|
||||
pub mod io;
|
||||
#[cfg(feature = "license")]
|
||||
pub mod license;
|
||||
pub mod mcp;
|
||||
pub mod misc;
|
||||
@ -19,7 +18,6 @@ use crate::model::error::database::DatabaseError;
|
||||
use crate::model::error::ebpf::EbpfError;
|
||||
use crate::model::error::http::HttpError;
|
||||
use crate::model::error::io::IOError;
|
||||
#[cfg(feature = "license")]
|
||||
use crate::model::error::license::LicenseError;
|
||||
use crate::model::error::mcp::McpError;
|
||||
use crate::model::error::misc::MiscError;
|
||||
@ -42,7 +40,6 @@ pub enum Error {
|
||||
ML(MLError),
|
||||
#[error("{0}")]
|
||||
IO(IOError),
|
||||
#[cfg(feature = "license")]
|
||||
#[error("{0}")]
|
||||
License(LicenseError),
|
||||
#[error("{0}")]
|
||||
@ -87,7 +84,6 @@ impl From<IOError> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "license")]
|
||||
impl From<LicenseError> for Error {
|
||||
fn from(error: LicenseError) -> Self {
|
||||
Self::License(error)
|
||||
|
||||
@ -46,5 +46,8 @@ traceable! {
|
||||
|
||||
#[error("Failed to mark setup as complete: {err}")]
|
||||
SetupCompleteFlagFailed => tracing::Level::ERROR,
|
||||
|
||||
#[error("Failed to write license file during setup: {err}")]
|
||||
SetupLicenseWriteFailed => tracing::Level::WARN,
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,5 +122,20 @@ loggable! {
|
||||
|
||||
#[error("License valid — ingress={ingress_mac}, egress={egress_mac}, expires={expires}, days_remaining={days_remaining}")]
|
||||
LicenseValid { ingress_mac: String, egress_mac: String, expires: String, days_remaining: i64 } => tracing::Level::INFO,
|
||||
|
||||
#[error("License tier resolved: {tier} ({feature_count} features enabled)")]
|
||||
LicenseTierResolved { tier: String, feature_count: usize } => tracing::Level::INFO,
|
||||
|
||||
#[error("License uploaded — tier: {tier}")]
|
||||
LicenseUploaded { tier: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("License revalidation failed: {error}")]
|
||||
LicenseRevalidationFailed { error: String } => tracing::Level::WARN,
|
||||
|
||||
#[error("License status changed: {from} → {to}")]
|
||||
LicenseStatusChanged { from: String, to: String } => tracing::Level::WARN,
|
||||
|
||||
#[error("License feature blocked: {feature} on path {path} (requires {required_tier})")]
|
||||
LicenseFeatureBlocked { feature: String, path: String, required_tier: String } => tracing::Level::WARN,
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,5 +19,4 @@ pub use monitoring::flow_stats;
|
||||
pub use monitoring::user_packet;
|
||||
pub use system::config;
|
||||
pub use system::health;
|
||||
#[cfg(feature = "license")]
|
||||
pub use system::license;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -24,3 +26,78 @@ impl LicenseInfo {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime license status with grace period support.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "days")]
|
||||
pub enum LicenseStatus {
|
||||
/// Valid license, more than 30 days remaining.
|
||||
Active,
|
||||
/// Valid license, ≤30 days remaining.
|
||||
ExpiringWarning(i64),
|
||||
/// Expired but within 14-day grace period. Value = days left in grace.
|
||||
GracePeriod(i64),
|
||||
/// Past grace period — community tier only.
|
||||
Expired,
|
||||
/// No license file / community mode.
|
||||
Unlicensed,
|
||||
}
|
||||
|
||||
impl LicenseStatus {
|
||||
/// Compute status from days remaining (positive = not yet expired).
|
||||
pub fn from_days_remaining(days: i64) -> Self {
|
||||
if days > 30 {
|
||||
Self::Active
|
||||
} else if days > 0 {
|
||||
Self::ExpiringWarning(days)
|
||||
} else if days > -14 {
|
||||
// days is 0 or negative; grace days left = 14 + days
|
||||
Self::GracePeriod(14 + days)
|
||||
} else {
|
||||
Self::Expired
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether licensed features should still be active.
|
||||
pub fn features_active(&self) -> bool {
|
||||
matches!(self, Self::Active | Self::ExpiringWarning(_) | Self::GracePeriod(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// Full license state held at runtime by LicenseService.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LicenseState {
|
||||
pub info: LicenseInfo,
|
||||
pub status: LicenseStatus,
|
||||
pub enabled_features: HashSet<String>,
|
||||
pub tier: String,
|
||||
}
|
||||
|
||||
/// API response for GET /api/system/features (frontend UI gating).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FeaturesResponse {
|
||||
pub tier: String,
|
||||
pub status: LicenseStatus,
|
||||
pub days_remaining: i64,
|
||||
pub enabled: Vec<String>,
|
||||
pub all_features: std::collections::HashMap<String, bool>,
|
||||
}
|
||||
|
||||
/// API response for GET /api/system/license (extended).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LicenseResponse {
|
||||
pub valid: bool,
|
||||
pub status: LicenseStatus,
|
||||
pub tier: String,
|
||||
pub enabled_features: Vec<String>,
|
||||
pub days_remaining: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub payload: Option<LicensePayloadPublic>,
|
||||
}
|
||||
|
||||
/// Public-facing payload (no MAC addresses exposed).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LicensePayloadPublic {
|
||||
pub expires: String,
|
||||
pub features: Vec<String>,
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
pub mod config;
|
||||
pub mod health;
|
||||
#[cfg(feature = "license")]
|
||||
pub mod license;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user