feat: Ed25519 license validation and generator

Validator (net-guardia): Reads license.key (base64 payload + signature),
verifies Ed25519 signature against embedded public key, checks expiry.
Optional — missing license logs warning, invalid/expired fails startup.
GET /api/system/license exposes license info.

Generator (license-generator): Standalone crate, not in workspace.
Subcommands: keygen (Ed25519 keypair), issue (sign license with
device_id/expires/features), verify (check license file).

Public key placeholder (all zeros) — replace after running keygen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaLaw2 2026-03-21 16:14:23 +08:00
parent 2e9dbe514f
commit 34d1832e21
24 changed files with 1003 additions and 41 deletions

7
.gitignore vendored
View File

@ -26,3 +26,10 @@ net-guardia/static/web
# Profiling
*.profraw
*.profdata
# License keys
license-generator/target/
*.hex
license.key
license_priv.key
license_pub.key

342
Cargo.lock generated
View File

@ -318,6 +318,18 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "assert_matches"
version = "1.5.0"
@ -463,6 +475,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bindgen"
version = "0.72.1"
@ -504,6 +522,15 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -534,6 +561,12 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byteorder"
version = "1.5.0"
@ -642,6 +675,12 @@ dependencies = [
"serde",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "convert_case"
version = "0.10.0"
@ -761,12 +800,49 @@ dependencies = [
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
@ -818,6 +894,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@ -855,6 +932,31 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core 0.6.4",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "egress-ebpf"
version = "0.1.0"
@ -897,6 +999,24 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filetime"
version = "0.2.27"
@ -1011,8 +1131,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@ -1095,6 +1217,15 @@ dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "http"
version = "0.2.12"
@ -1315,6 +1446,31 @@ dependencies = [
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "kstring"
version = "2.0.2"
@ -1382,6 +1538,17 @@ dependencies = [
"redox_syscall 0.7.3",
]
[[package]]
name = "libsqlite3-sys"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libxdp-sys"
version = "0.2.4+1.6.0"
@ -1638,14 +1805,18 @@ dependencies = [
"actix-cors",
"actix-web",
"actix-ws",
"argon2",
"aya",
"aya-log",
"base64",
"cargo_metadata",
"common",
"crossbeam",
"dotenvy",
"ed25519-dalek",
"futures-util",
"ipnetwork",
"jsonwebtoken",
"libc",
"libxdp-sys",
"lru",
@ -1654,6 +1825,8 @@ dependencies = [
"mime_guess",
"network-types",
"parking_lot",
"rand 0.9.2",
"rusqlite",
"rust-embed",
"serde",
"serde_json",
@ -1737,6 +1910,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
@ -1852,12 +2035,33 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -1913,6 +2117,16 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
@ -2170,6 +2384,34 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rust-embed"
version = "8.11.0"
@ -2413,12 +2655,33 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "slab"
version = "0.4.12"
@ -2451,6 +2714,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@ -2480,6 +2753,12 @@ dependencies = [
"serde",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
@ -3025,6 +3304,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@ -3055,6 +3340,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
@ -3086,6 +3377,51 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.117",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [
"unicode-ident",
]
[[package]]
name = "which"
version = "8.0.2"
@ -3423,6 +3759,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"

View File

@ -54,6 +54,9 @@ rusqlite = { version = "0.34", features = ["bundled"] }
jsonwebtoken = "9"
argon2 = "0.5"
rand = "0.9"
ed25519-dalek = { version = "2", features = ["std", "rand_core"] }
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
# Build dependencies
cargo_metadata = { version = "0.23.1", default-features = false }

View File

@ -30,6 +30,7 @@ traffic_log_csv_path = "traffic_log.csv"
[Misc]
geoip_db_name = "net-guardia/static/geo/GeoLite2-City.mmdb"
database_path = "net-guardia.db"
license_file = "license.key"
[Pipeline]
ingress = ["access_control", "rate_limit", "service"]

View File

@ -0,0 +1,13 @@
[package]
name = "license-generator"
version = "0.1.0"
edition = "2024"
[dependencies]
ed25519-dalek = { version = "2", features = ["std", "rand_core"] }
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rand = "0.9"
clap = { version = "4", features = ["derive"] }
pnet = "0.36"

View File

@ -0,0 +1,217 @@
use std::fs;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use clap::{Parser, Subcommand};
use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey, Signature};
use pnet::datalink;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
#[derive(Parser)]
#[command(name = "license-generator", about = "NetGuardia license generator")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Generate a new Ed25519 keypair
Keygen {
#[arg(short, long, default_value = "license")]
prefix: String,
},
/// Issue a signed license bound to NIC MACs
Issue {
#[arg(short = 'k', long)]
private_key: String,
/// Ingress interface name (e.g. ng-ext)
#[arg(long)]
ingress: String,
/// Egress interface name (e.g. ng-int)
#[arg(long)]
egress: String,
/// Expiry date (YYYY-MM-DD)
#[arg(short, long)]
expires: String,
/// Comma-separated list of features
#[arg(short, long, default_value = "")]
features: String,
/// Output license file path
#[arg(short, long, default_value = "license.key")]
output: String,
},
/// Verify a license file
Verify {
#[arg(short = 'k', long)]
public_key: String,
#[arg(short, long)]
license: String,
},
}
#[derive(Serialize, Deserialize, Debug)]
struct LicensePayload {
ingress_mac: String,
egress_mac: String,
expires: String,
features: Vec<String>,
}
fn get_mac(ifname: &str) -> String {
for iface in datalink::interfaces() {
if iface.name == ifname {
if let Some(mac) = iface.mac {
return format!(
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
mac.0, mac.1, mac.2, mac.3, mac.4, mac.5
);
}
}
}
eprintln!("Interface '{}' not found or has no MAC address", ifname);
eprintln!("Available interfaces:");
for iface in datalink::interfaces() {
if let Some(mac) = iface.mac {
eprintln!(" {}{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
iface.name, mac.0, mac.1, mac.2, mac.3, mac.4, mac.5);
}
}
std::process::exit(1);
}
fn main() {
let cli = Cli::parse();
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::Verify { public_key, license } => verify(&public_key, &license),
}
}
fn keygen(prefix: &str) {
let mut csprng = OsRng;
let signing_key = SigningKey::generate(&mut csprng);
let verifying_key = signing_key.verifying_key();
let priv_hex = hex_encode(signing_key.as_bytes());
let pub_hex = hex_encode(verifying_key.as_bytes());
let priv_path = format!("{}_priv.key", prefix);
let pub_path = format!("{}_pub.key", prefix);
fs::write(&priv_path, &priv_hex).expect("Failed to write private key");
fs::write(&pub_path, &pub_hex).expect("Failed to write public key");
println!("Keypair generated:");
println!(" Private key: {}", priv_path);
println!(" Public key: {}", pub_path);
println!();
println!("Public key hex (embed in validator.rs):");
println!(" {}", pub_hex);
}
fn issue(private_key_path: &str, ingress: &str, egress: &str, expires: &str, features: &str, output: &str) {
let ingress_mac = get_mac(ingress);
let egress_mac = get_mac(egress);
println!("Detected MACs:");
println!(" {}{}", ingress, ingress_mac);
println!(" {}{}", egress, egress_mac);
let priv_hex = fs::read_to_string(private_key_path)
.expect("Failed to read private key")
.trim()
.to_string();
let priv_bytes = hex_decode(&priv_hex).expect("Invalid hex");
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() {
vec![]
} else {
features.split(',').map(|s| s.trim().to_string()).collect()
};
let payload = LicensePayload {
ingress_mac: ingress_mac.clone(),
egress_mac: egress_mac.clone(),
expires: expires.to_string(),
features: feature_list,
};
let payload_json = serde_json::to_string(&payload).expect("Failed to serialize");
let payload_b64 = BASE64.encode(payload_json.as_bytes());
let signature: Signature = signing_key.sign(payload_b64.as_bytes());
let sig_b64 = BASE64.encode(signature.to_bytes());
let license_content = format!("{}.{}", payload_b64, sig_b64);
fs::write(output, &license_content).expect("Failed to write license");
println!();
println!("License issued:");
println!(" Ingress MAC: {}", ingress_mac);
println!(" Egress MAC: {}", egress_mac);
println!(" Expires: {}", expires);
println!(" Features: {:?}", payload.features);
println!(" Output: {}", output);
}
fn verify(public_key_path: &str, license_path: &str) {
let pub_hex = fs::read_to_string(public_key_path)
.expect("Failed to read public key")
.trim()
.to_string();
let pub_bytes = hex_decode(&pub_hex).expect("Invalid hex");
let pub_array: [u8; 32] = pub_bytes.try_into().expect("Key must be 32 bytes");
let verifying_key = VerifyingKey::from_bytes(&pub_array).expect("Invalid public key");
let contents = fs::read_to_string(license_path)
.expect("Failed to read license")
.trim()
.to_string();
let parts: Vec<&str> = contents.splitn(2, '.').collect();
if parts.len() != 2 {
eprintln!("Invalid license format");
std::process::exit(1);
}
let sig_bytes = BASE64.decode(parts[1]).expect("Invalid signature");
let sig_array: [u8; 64] = sig_bytes.try_into().expect("Signature must be 64 bytes");
let signature = Signature::from_bytes(&sig_array);
match verifying_key.verify(parts[0].as_bytes(), &signature) {
Ok(()) => {
let payload_bytes = BASE64.decode(parts[0]).expect("Invalid payload");
let payload: LicensePayload = serde_json::from_slice(&payload_bytes).expect("Invalid JSON");
println!("License VALID:");
println!(" Ingress MAC: {}", payload.ingress_mac);
println!(" Egress MAC: {}", payload.egress_mac);
println!(" Expires: {}", payload.expires);
println!(" Features: {:?}", payload.features);
}
Err(e) => {
eprintln!("License INVALID: {}", e);
std::process::exit(1);
}
}
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
if hex.len() % 2 != 0 {
return Err("Odd-length hex string".to_string());
}
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| e.to_string()))
.collect()
}

View File

@ -54,6 +54,12 @@ rusqlite = { workspace = true }
jsonwebtoken = { workspace = true }
argon2 = { workspace = true }
rand = { workspace = true }
ed25519-dalek = { workspace = true, optional = true }
base64 = { workspace = true, optional = true }
[features]
default = []
license = ["dep:ed25519-dalek", "dep:base64"]
[build-dependencies]
cargo_metadata = { workspace = true }

View File

@ -11,6 +11,38 @@ fn main() {
build_ebpf_package("ingress-ebpf", "ingress-ebpf");
build_ebpf_package("egress-ebpf", "egress-ebpf");
build_frontend();
embed_license_public_key();
}
fn embed_license_public_key() {
// Only needed when license feature is enabled
let license_enabled = env::var("CARGO_FEATURE_LICENSE").is_ok();
let project_root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap())
.parent()
.unwrap()
.to_path_buf();
let key_path = project_root.join("license_pub.key");
println!("cargo:rerun-if-changed={}", key_path.display());
if key_path.exists() {
let key_hex = fs::read_to_string(&key_path)
.expect("Failed to read license_pub.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 {
// Feature not enabled, set clearly invalid placeholder (won't be used)
println!("cargo:rustc-env=LICENSE_PUBLIC_KEY=DISABLED");
}
}
fn build_ebpf_package(package_name: &str, target_subdir: &str) {

View File

@ -1,4 +1,4 @@
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use jsonwebtoken::{decode, encode, errors::ErrorKind, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::core::database::Database;
@ -60,10 +60,9 @@ impl JwtService {
pub fn validate_token(&self, token: &str) -> Result<Claims, Error> {
let token_data = decode::<Claims>(token, &self.decoding_key, &Validation::default())
.map_err(|e| {
if e.to_string().contains("ExpiredSignature") {
Error::from(AuthError::TokenExpired)
} else {
Error::from(AuthError::InvalidToken)
match e.kind() {
ErrorKind::ExpiredSignature => Error::from(AuthError::TokenExpired),
_ => Error::from(AuthError::InvalidToken),
}
})?;
Ok(token_data.claims)

View File

@ -1,6 +1,6 @@
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use rand::rngs::OsRng;
use crate::model::error::auth::AuthError;
use crate::model::error::Error;

View File

@ -221,7 +221,7 @@ impl Database {
conn.execute(
"INSERT INTO users (username, password_hash, role) VALUES (?1, ?2, ?3)",
params![username, password_hash, role],
).map_err(|e| {
).map_err(|e| -> Error {
if e.to_string().contains("UNIQUE constraint") {
DatabaseError::UserAlreadyExists { username: username.to_string() }.into()
} else {
@ -234,6 +234,6 @@ impl Database {
pub fn user_count(&self) -> Result<i64, Error> {
let conn = self.conn.lock();
conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() }.into())
.map_err(|e| -> Error { DatabaseError::QueryFailed { reason: e.to_string() }.into() })
}
}

View File

@ -5,7 +5,6 @@ use aya::maps::lpm_trie::{Key, LpmTrie};
use aya::maps::MapData;
use aya::Ebpf;
use ipnetwork::IpNetwork;
use macros::log;
use maxminddb::{geoip2, Reader};
use parking_lot::RwLock;
@ -13,7 +12,6 @@ use crate::core::infrastructure::app_config::AppConfig;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::misc::MiscError;
use crate::model::error::Error;
use crate::model::log::system::SystemLog;
/// Pre-indexed GeoIP prefix table, built once at startup.
struct GeoIndex {

View File

@ -0,0 +1,3 @@
pub mod validator;
pub use validator::LicenseInfo;

View File

@ -0,0 +1,235 @@
use std::path::Path;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use ed25519_dalek::{Signature, VerifyingKey, Verifier};
use serde::{Deserialize, Serialize};
use crate::model::error::license::LicenseError;
/// Public key auto-embedded from license_pub.key at compile time.
/// Generate with: cd license-generator && cargo run -- keygen
/// Then place license_pub.key in the repo root.
const PUBLIC_KEY_HEX: &str = env!("LICENSE_PUBLIC_KEY");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicensePayload {
pub ingress_mac: String,
pub egress_mac: String,
pub expires: String,
pub features: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseInfo {
pub payload: Option<LicensePayload>,
pub valid: bool,
pub days_remaining: i64,
}
impl LicenseInfo {
pub fn unlicensed() -> Self {
Self {
payload: None,
valid: false,
days_remaining: 0,
}
}
}
pub fn validate_license(license_path: &str, ingress_ifname: &str, egress_ifname: &str) -> Result<LicenseInfo, crate::model::error::Error> {
// Guard against builds where the license feature was not configured
if PUBLIC_KEY_HEX == "DISABLED" {
return Err(LicenseError::ValidationFailed {
reason: "License validation not configured".to_string(),
}.into());
}
// If path is empty, license is optional — return unlicensed
if license_path.is_empty() {
tracing::warn!("No license file configured — running without license");
return Ok(LicenseInfo::unlicensed());
}
let path = Path::new(license_path);
if !path.exists() {
tracing::warn!("License file '{}' not found — running without license", license_path);
return Ok(LicenseInfo::unlicensed());
}
let contents = std::fs::read_to_string(path)
.map_err(|_| LicenseError::FileNotFound { path: license_path.to_string() })?;
let contents = contents.trim();
// Format: base64(json_payload).base64(ed25519_signature)
let parts: Vec<&str> = contents.splitn(2, '.').collect();
if parts.len() != 2 {
return Err(LicenseError::ValidationFailed {
reason: "Invalid license format: expected <payload>.<signature>".to_string(),
}.into());
}
let payload_b64 = parts[0];
let signature_b64 = parts[1];
// Decode payload
let payload_bytes = BASE64.decode(payload_b64)
.map_err(|e| LicenseError::ValidationFailed {
reason: format!("Failed to decode payload: {}", e),
})?;
// Decode signature
let sig_bytes = BASE64.decode(signature_b64)
.map_err(|e| LicenseError::ValidationFailed {
reason: format!("Failed to decode signature: {}", e),
})?;
// Parse public key
let pub_key_bytes = hex_decode(PUBLIC_KEY_HEX)
.map_err(|e| LicenseError::ValidationFailed {
reason: format!("Invalid embedded public key: {}", e),
})?;
let pub_key_array: [u8; 32] = pub_key_bytes.try_into()
.map_err(|_| LicenseError::ValidationFailed {
reason: "Public key must be 32 bytes".to_string(),
})?;
let verifying_key = VerifyingKey::from_bytes(&pub_key_array)
.map_err(|_| LicenseError::ValidationFailed {
reason: "Invalid public key".to_string(),
})?;
// Parse signature
let sig_array: [u8; 64] = sig_bytes.try_into()
.map_err(|_| LicenseError::ValidationFailed {
reason: "Signature must be 64 bytes".to_string(),
})?;
let signature = Signature::from_bytes(&sig_array);
// Verify signature over the raw base64-encoded payload (not decoded bytes)
verifying_key.verify(payload_b64.as_bytes(), &signature)
.map_err(|_| LicenseError::InvalidSignature)?;
// Parse payload JSON
let payload: LicensePayload = serde_json::from_slice(&payload_bytes)
.map_err(|e| LicenseError::ValidationFailed {
reason: format!("Failed to parse license payload: {}", e),
})?;
// Verify NIC MAC addresses
let actual_ingress_mac = get_interface_mac(ingress_ifname).unwrap_or_default();
let actual_egress_mac = get_interface_mac(egress_ifname).unwrap_or_default();
if actual_ingress_mac != payload.ingress_mac {
return Err(LicenseError::ValidationFailed {
reason: "Ingress MAC mismatch — license not bound to this device".to_string(),
}.into());
}
if actual_egress_mac != payload.egress_mac {
return Err(LicenseError::ValidationFailed {
reason: "Egress MAC mismatch — license not bound to this device".to_string(),
}.into());
}
// Check expiry
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());
}
tracing::info!(
"License valid — ingress={}, egress={}, expires={}, days_remaining={}, features={:?}",
payload.ingress_mac, payload.egress_mac, payload.expires, days_remaining, payload.features
);
Ok(LicenseInfo {
payload: Some(payload),
valid: true,
days_remaining,
})
}
/// Read MAC address from /sys/class/net/<ifname>/address (Linux only).
fn get_interface_mac(ifname: &str) -> Option<String> {
// Prevent path traversal
if !ifname.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return None;
}
let path = format!("/sys/class/net/{}/address", ifname);
std::fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_lowercase())
}
/// Simple hex decoder without external dependency.
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
if hex.len() % 2 != 0 {
return Err("Odd-length hex string".to_string());
}
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| e.to_string()))
.collect()
}
/// 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()
.as_secs() as i64;
let days_since_epoch = (secs / 86400) as i32;
epoch_days_to_ymd(days_since_epoch)
}
fn parse_date(s: &str) -> Result<(i32, u32, u32), String> {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() != 3 {
return Err("Expected YYYY-MM-DD".to_string());
}
let y = parts[0].parse::<i32>().map_err(|e| e.to_string())?;
let m = parts[1].parse::<u32>().map_err(|e| e.to_string())?;
let d = parts[2].parse::<u32>().map_err(|e| e.to_string())?;
Ok((y, m, d))
}
fn ymd_to_epoch_days(y: i32, m: u32, d: u32) -> i32 {
// Algorithm from Howard Hinnant
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u32;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe as i32 - 719468
}
fn epoch_days_to_ymd(days: i32) -> (i32, u32, u32) {
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i32 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn days_until(expiry_str: &str, today: &(i32, u32, u32)) -> Result<i64, String> {
let (ey, em, ed) = parse_date(expiry_str)?;
let expiry_days = ymd_to_epoch_days(ey, em, ed) as i64;
let today_days = ymd_to_epoch_days(today.0, today.1, today.2) as i64;
Ok(expiry_days - today_days)
}

View File

@ -30,7 +30,6 @@ pub struct Engine {
min_packets: usize,
batch_size: usize,
inference_interval_secs: u64,
flow_timeout_us: u64,
traffic_logger: Option<Arc<TrafficLogger>>,
}
@ -61,7 +60,6 @@ impl Engine {
min_packets: engine_config.min_packets,
batch_size: engine_config.batch_size,
inference_interval_secs: engine_config.inference_interval_secs,
flow_timeout_us: engine_config.flow_timeout_us,
traffic_logger,
}
}

View File

@ -2,5 +2,7 @@ pub mod auth;
pub mod database;
pub mod ebpf;
pub mod infrastructure;
#[cfg(feature = "license")]
pub mod license;
pub mod ml;
pub mod system;

View File

@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
use std::sync::Arc;
use actix_web::web::route;
@ -16,11 +17,17 @@ use crate::core::database::Database;
use crate::core::ebpf::EbpfServices;
use crate::core::infrastructure::app_config::AppConfig;
use crate::core::infrastructure::MLService;
#[cfg(feature = "license")]
use crate::core::license::LicenseInfo;
#[cfg(feature = "license")]
use crate::core::license::validator::validate_license;
use crate::core::ml::config_loader::InferenceConfig;
use crate::model::error::ebpf::EbpfError;
use crate::model::error::http::HttpError;
use crate::model::error::misc::MiscError;
use crate::model::direction::FlowDirection;
use crate::model::error::Error;
use crate::model::list_type::ListType;
use crate::model::log::ml::MLLog;
use crate::model::log::system::SystemLog;
use crate::utils::logging::Logging;
@ -42,6 +49,8 @@ pub struct System {
pub app_services: Arc<MLService>,
pub db: Arc<Database>,
pub jwt_service: Arc<JwtService>,
#[cfg(feature = "license")]
pub license_info: Arc<LicenseInfo>,
pub ingress_ebpf: Ebpf,
pub egress_ebpf: Ebpf,
#[allow(dead_code)]
@ -54,6 +63,13 @@ impl System {
let mut egress_ebpf = Self::load_ebpf("egress")?;
let app_config = Arc::new(AppConfig::new()?);
#[cfg(feature = "license")]
let license_info = Arc::new(validate_license(
&app_config.misc.license_file,
&app_config.network.ingress_ifname,
&app_config.network.egress_ifname,
)?);
let ingress_program_array = Self::configure_ingress_pipeline(
&mut ingress_ebpf,
&app_config.pipeline.ingress,
@ -128,6 +144,61 @@ impl System {
}
}
// Load persisted ACL rules
if let Ok(rules) = db.load_acl_rules() {
let mut restored = 0u32;
for (ip_version, direction, list_type, ip_address, port) in &rules {
let dir = match direction.as_str() {
"source" => FlowDirection::Source,
"destination" => FlowDirection::Destination,
other => {
tracing::warn!("Unknown ACL direction '{}', skipping", other);
continue;
}
};
let lt = match list_type.as_str() {
"whitelist" => ListType::White,
"blacklist" => ListType::Black,
other => {
tracing::warn!("Unknown ACL list type '{}', skipping", other);
continue;
}
};
let result = match ip_version {
4 => {
match ip_address.parse::<Ipv4Addr>() {
Ok(addr) => ebpf_services.access_control.add_ipv4_list(dir, lt, SocketAddrV4::new(addr, *port)).await,
Err(e) => {
tracing::warn!("Failed to parse IPv4 address '{}': {}", ip_address, e);
continue;
}
}
}
6 => {
match ip_address.parse::<Ipv6Addr>() {
Ok(addr) => ebpf_services.access_control.add_ipv6_list(dir, lt, SocketAddrV6::new(addr, *port, 0, 0)).await,
Err(e) => {
tracing::warn!("Failed to parse IPv6 address '{}': {}", ip_address, e);
continue;
}
}
}
other => {
tracing::warn!("Unknown IP version {}, skipping", other);
continue;
}
};
if let Err(e) = result {
tracing::warn!("Failed to restore ACL rule ({} {} {}:{}): {}", direction, list_type, ip_address, port, e);
} else {
restored += 1;
}
}
if restored > 0 {
tracing::info!("Restored {} ACL rules from database", restored);
}
}
Ok(System {
app_config,
inference_config,
@ -135,6 +206,8 @@ impl System {
app_services,
db,
jwt_service,
#[cfg(feature = "license")]
license_info,
ingress_ebpf,
egress_ebpf,
ingress_program_array,
@ -225,6 +298,8 @@ impl System {
let drop_monitor = self.ebpf_services.drop_monitor.clone();
let db = self.db.clone();
let jwt_service = self.jwt_service.clone();
#[cfg(feature = "license")]
let license_info = self.license_info.clone();
let port = self.app_config.http.http_server_bind_port;
HttpServer::new(move || {
let cors = actix_cors::Cors::default()
@ -233,7 +308,7 @@ impl System {
.allow_any_method()
.allow_any_header()
.max_age(3600);
App::new()
let app = App::new()
.wrap(cors)
.app_data(web::Data::from(app_config.clone()))
.app_data(web::Data::from(inference_config.clone()))
@ -248,8 +323,10 @@ impl System {
.app_data(web::Data::from(flow_statistics.clone()))
.app_data(web::Data::from(drop_monitor.clone()))
.app_data(web::Data::from(db.clone()))
.app_data(web::Data::from(jwt_service.clone()))
.service(
.app_data(web::Data::from(jwt_service.clone()));
#[cfg(feature = "license")]
let app = app.app_data(web::Data::from(license_info.clone()));
app.service(
web::scope("/api")
.wrap(crate::core::auth::middleware::AuthMiddleware)
.service(auth::initialize())

View File

@ -64,9 +64,12 @@ pub struct MiscConfig {
pub geoip_db_name: String,
#[serde(default = "default_db_path")]
pub database_path: String,
#[serde(default = "default_license_path")]
pub license_file: String,
}
fn default_db_path() -> String { "net-guardia.db".to_string() }
fn default_license_path() -> String { "license.key".to_string() }
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PipelineConfig {

View File

@ -2,18 +2,23 @@ use macros::traceable;
traceable! {
AuthError {
#[no_source]
#[error("Invalid credentials")]
InvalidCredentials => tracing::Level::WARN,
#[no_source]
#[error("Token expired")]
TokenExpired => tracing::Level::WARN,
#[no_source]
#[error("Invalid token")]
InvalidToken => tracing::Level::WARN,
#[no_source]
#[error("Insufficient permissions")]
InsufficientPermissions => tracing::Level::WARN,
#[no_source]
#[error("Missing authorization header")]
MissingAuthHeader => tracing::Level::WARN,
}

View File

@ -0,0 +1,19 @@
use macros::traceable;
traceable! {
LicenseError {
#[no_source]
#[error("License file not found: {path}")]
FileNotFound { path: String } => tracing::Level::WARN,
#[error("Invalid license signature")]
InvalidSignature => tracing::Level::ERROR,
#[error("License has expired")]
Expired => tracing::Level::WARN,
#[no_source]
#[error("License validation failed: {reason}")]
ValidationFailed { reason: String } => tracing::Level::ERROR,
}
}

View File

@ -3,6 +3,8 @@ pub mod database;
pub mod ebpf;
pub mod http;
pub mod io;
#[cfg(feature = "license")]
pub mod license;
pub mod misc;
pub mod ml;
pub mod system;
@ -14,6 +16,8 @@ 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::misc::MiscError;
use crate::model::error::ml::MLError;
use crate::model::error::system::SystemError;
@ -32,6 +36,9 @@ pub enum Error {
ML(MLError),
#[error("{0}")]
IO(IOError),
#[cfg(feature = "license")]
#[error("{0}")]
License(LicenseError),
#[error("{0}")]
Misc(MiscError),
#[error("{0}")]
@ -68,6 +75,13 @@ impl From<IOError> for Error {
}
}
#[cfg(feature = "license")]
impl From<LicenseError> for Error {
fn from(error: LicenseError) -> Self {
Self::License(error)
}
}
impl From<MiscError> for Error {
fn from(error: MiscError) -> Self {
Self::Misc(error)
@ -84,4 +98,4 @@ impl From<MLError> for Error {
fn from(error: MLError) -> Self {
Self::ML(error)
}
}
}

View File

@ -1,5 +1,3 @@
use std::net::{Ipv4Addr, Ipv6Addr};
pub struct UserPacket {
pub ip_version: u8,
pub protocol: u8,
@ -15,23 +13,3 @@ pub struct UserPacket {
pub timestamp_us: u64,
pub is_forward: bool,
}
impl UserPacket {
/// Format source IP as a human-readable string.
pub fn src_ip_string(&self) -> String {
Self::ip_bytes_to_string(self.ip_version, &self.src_ip)
}
/// Format destination IP as a human-readable string.
pub fn dst_ip_string(&self) -> String {
Self::ip_bytes_to_string(self.ip_version, &self.dst_ip)
}
fn ip_bytes_to_string(ip_version: u8, bytes: &[u8; 16]) -> String {
if ip_version == 6 {
Ipv6Addr::from(*bytes).to_string()
} else {
Ipv4Addr::from([bytes[0], bytes[1], bytes[2], bytes[3]]).to_string()
}
}
}

View File

@ -1,4 +1,4 @@
use actix_web::{web, HttpRequest, HttpResponse, Responder, Scope};
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse, Responder, Scope};
use serde::Deserialize;
use crate::core::auth::jwt::{Claims, JwtService};

View File

@ -1,10 +1,20 @@
use actix_web::{web, HttpResponse, Responder, Scope};
pub fn initialize() -> Scope {
web::scope("/system")
.route("/boot-time", web::get().to(get_boot_time))
let scope = web::scope("/system")
.route("/boot-time", web::get().to(get_boot_time));
#[cfg(feature = "license")]
let scope = scope.route("/license", web::get().to(get_license_info));
scope
}
async fn get_boot_time() -> impl Responder {
HttpResponse::Ok().json(crate::utils::boot_time::boot_time())
}
#[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())
}