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:
DaLaw2 2026-03-24 22:29:26 +08:00
parent 4ba660ee57
commit 73be8e5eaa
23 changed files with 813 additions and 79 deletions

View File

@ -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"

View File

@ -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()

View File

@ -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 }

View File

@ -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");
}
}

View File

@ -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));

View File

@ -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)
}

View 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"
}
}

View File

@ -1,5 +1,6 @@
pub mod extractor;
pub mod jwt;
pub mod license_guard;
pub mod middleware;
pub mod password;
pub mod setup_guard;

View 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);
}
}
}

View File

@ -1,3 +1,3 @@
pub mod features;
pub mod service;
pub mod validator;
pub use crate::model::license::LicenseInfo;

View 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"));
}
}

View File

@ -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();

View File

@ -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;

View File

@ -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(),

View File

@ -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())

View File

@ -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,

View File

@ -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,
}
}

View File

@ -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)

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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;

View File

@ -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>,
}

View File

@ -1,4 +1,3 @@
pub mod config;
pub mod health;
#[cfg(feature = "license")]
pub mod license;