Initial commit
This commit is contained in:
1711
license-system-launcher/Cargo.lock
generated
Normal file
1711
license-system-launcher/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
license-system-launcher/Cargo.toml
Normal file
23
license-system-launcher/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "license-system-launcher"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
mac_address = "1.1"
|
||||
local-ip-address = "0.5"
|
||||
rand = "0.8"
|
||||
base64 = "0.22"
|
||||
eframe = "0.27"
|
||||
rfd = "0.14"
|
||||
82
license-system-launcher/README.md
Normal file
82
license-system-launcher/README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# License Launcher (Windows)
|
||||
|
||||
Single-file launcher that verifies license online, then runs the embedded EXE.
|
||||
|
||||
## Build (Windows)
|
||||
|
||||
1) Install Rust (MSVC toolchain).
|
||||
2) Build:
|
||||
|
||||
```
|
||||
cargo build --release --bin launcher_stub
|
||||
cargo build --release --bin launcher_pack
|
||||
```
|
||||
|
||||
Output:
|
||||
- `target/release/launcher_stub.exe`
|
||||
- `target/release/launcher_pack.exe`
|
||||
- `target/release/launcher_gui.exe`
|
||||
|
||||
## Config
|
||||
|
||||
Create `config.json` (example):
|
||||
|
||||
```json
|
||||
{
|
||||
"api_base": "http://118.145.218.2:39256",
|
||||
"project_id": "PROJ_001",
|
||||
"project_secret": "YOUR_PROJECT_SECRET",
|
||||
"client_version": "1.0.0",
|
||||
"license_file": "license.key",
|
||||
"request_timeout_sec": 10,
|
||||
"heartbeat_retries": 2,
|
||||
"heartbeat_retry_delay_sec": 5,
|
||||
"extract_to": "temp",
|
||||
"keep_payload": false
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `extract_to`: `temp` (default) or `self`.
|
||||
- If `extract_to=self`, the payload is extracted next to the launcher (use this when your app needs local resource files).
|
||||
- Card key is read from `license.key` next to the launcher (first non-empty line). You can also set `card_key` in config to hardcode.
|
||||
|
||||
## Pack
|
||||
|
||||
```
|
||||
launcher_pack --stub launcher_stub.exe --input your_app.exe --config config.json --output your_app_protected.exe
|
||||
```
|
||||
|
||||
The tool writes the payload name into the config automatically.
|
||||
|
||||
## GUI Pack (Recommended)
|
||||
|
||||
1) Put `launcher_gui.exe` and `launcher_stub.exe` in the same folder.
|
||||
2) Run `launcher_gui.exe`.
|
||||
3) Fill:
|
||||
- API base
|
||||
- Integration code (LSC1... from Project detail)
|
||||
4) Drag your EXE into the window.
|
||||
5) Click **Start Pack**.
|
||||
|
||||
Output file defaults to `*_packed.exe`.
|
||||
|
||||
## Run
|
||||
|
||||
Place `license.key` next to `your_app_protected.exe`:
|
||||
|
||||
```
|
||||
YOUR-CARD-KEY
|
||||
```
|
||||
|
||||
Start `your_app_protected.exe`. It will:
|
||||
1) Compute deviceId = `MAC|IP` (clamped to 64 chars; hashed if too long).
|
||||
2) Call `/api/auth/verify` online.
|
||||
3) Start the embedded app.
|
||||
4) Send `/api/auth/heartbeat` on interval; failure will exit the app.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Online verification is required; no offline cache.
|
||||
- If the server disables heartbeat, only startup verification is enforced.
|
||||
- This raises reverse-engineering cost but cannot make a client uncrackable.
|
||||
12
license-system-launcher/config.example.json
Normal file
12
license-system-launcher/config.example.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"api_base": "http://118.145.218.2:39256",
|
||||
"project_id": "PROJ_001",
|
||||
"project_secret": "YOUR_PROJECT_SECRET",
|
||||
"client_version": "1.0.0",
|
||||
"license_file": "license.key",
|
||||
"request_timeout_sec": 10,
|
||||
"heartbeat_retries": 2,
|
||||
"heartbeat_retry_delay_sec": 5,
|
||||
"extract_to": "temp",
|
||||
"keep_payload": false
|
||||
}
|
||||
242
license-system-launcher/src/bin/launcher_gui.rs
Normal file
242
license-system-launcher/src/bin/launcher_gui.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use eframe::egui;
|
||||
|
||||
const MAGIC: &[u8] = b"LSWRAP1";
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let options = eframe::NativeOptions::default();
|
||||
eframe::run_native(
|
||||
"授权打包器",
|
||||
options,
|
||||
Box::new(|_| Box::new(LauncherGui::new())),
|
||||
)
|
||||
}
|
||||
|
||||
struct LauncherGui {
|
||||
api_base: String,
|
||||
integration_code: String,
|
||||
input_path: String,
|
||||
output_path: String,
|
||||
extract_to_self: bool,
|
||||
status: String,
|
||||
}
|
||||
|
||||
impl LauncherGui {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
api_base: String::new(),
|
||||
integration_code: String::new(),
|
||||
input_path: String::new(),
|
||||
output_path: String::new(),
|
||||
extract_to_self: false,
|
||||
status: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pack(&mut self) -> Result<(), String> {
|
||||
let api_base = normalize_api_base(&self.api_base)?;
|
||||
let (project_id, project_key) = decode_integration_code(&self.integration_code)?;
|
||||
|
||||
let input = PathBuf::from(self.input_path.trim());
|
||||
if !input.exists() {
|
||||
return Err("未选择可执行文件".to_string());
|
||||
}
|
||||
|
||||
let output = if self.output_path.trim().is_empty() {
|
||||
derive_output_path(&input)?
|
||||
} else {
|
||||
PathBuf::from(self.output_path.trim())
|
||||
};
|
||||
|
||||
if input == output {
|
||||
return Err("输出文件不能与输入文件相同".to_string());
|
||||
}
|
||||
|
||||
let exe_dir = env::current_exe()
|
||||
.map_err(|e| format!("无法定位程序目录: {e}"))?
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.to_path_buf();
|
||||
let stub_path = exe_dir.join("launcher_stub.exe");
|
||||
if !stub_path.exists() {
|
||||
return Err("未找到 launcher_stub.exe,请放在同目录".to_string());
|
||||
}
|
||||
|
||||
let payload_name = input
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("payload.exe")
|
||||
.to_string();
|
||||
|
||||
let extract_to = if self.extract_to_self { "self" } else { "temp" };
|
||||
|
||||
let config = serde_json::json!({
|
||||
"api_base": api_base,
|
||||
"project_id": project_id,
|
||||
"project_secret": project_key,
|
||||
"license_file": "license.key",
|
||||
"request_timeout_sec": 10,
|
||||
"heartbeat_retries": 2,
|
||||
"heartbeat_retry_delay_sec": 5,
|
||||
"extract_to": extract_to,
|
||||
"keep_payload": false,
|
||||
"payload_name": payload_name
|
||||
});
|
||||
let config_bytes = serde_json::to_vec(&config)
|
||||
.map_err(|e| format!("配置序列化失败: {e}"))?;
|
||||
|
||||
pack_files(&stub_path, &input, &output, &config_bytes)?;
|
||||
self.output_path = output.to_string_lossy().to_string();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for LauncherGui {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
handle_drop(ctx, self);
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.heading("授权打包器");
|
||||
ui.add_space(8.0);
|
||||
|
||||
ui.label("授权系统地址");
|
||||
ui.add(egui::TextEdit::singleline(&mut self.api_base).hint_text("http://118.145.218.2:39256"));
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.label("项目对接码");
|
||||
ui.add(egui::TextEdit::singleline(&mut self.integration_code).hint_text("LSC1.xxxxx"));
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.label("选择要打包的 EXE");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut self.input_path).desired_width(360.0));
|
||||
if ui.button("浏览").clicked() {
|
||||
if let Some(file) = rfd::FileDialog::new().add_filter("EXE", &["exe"]).pick_file() {
|
||||
self.input_path = file.to_string_lossy().to_string();
|
||||
if self.output_path.trim().is_empty() {
|
||||
if let Ok(path) = derive_output_path(&file) {
|
||||
self.output_path = path.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.label("输出文件");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut self.output_path).desired_width(360.0));
|
||||
if ui.button("另存为").clicked() {
|
||||
if let Some(file) = rfd::FileDialog::new().add_filter("EXE", &["exe"]).set_file_name("packed.exe").save_file() {
|
||||
self.output_path = file.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.checkbox(&mut self.extract_to_self, "解压到程序目录(需要本地资源时勾选)");
|
||||
|
||||
ui.add_space(10.0);
|
||||
if ui.button("开始打包").clicked() {
|
||||
match self.pack() {
|
||||
Ok(_) => self.status = "打包完成".to_string(),
|
||||
Err(err) => self.status = format!("打包失败: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
if !self.status.is_empty() {
|
||||
ui.add_space(8.0);
|
||||
ui.label(&self.status);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_drop(ctx: &egui::Context, app: &mut LauncherGui) {
|
||||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
||||
for file in dropped {
|
||||
if let Some(path) = file.path {
|
||||
if path.extension().and_then(|s| s.to_str()).map(|s| s.eq_ignore_ascii_case("exe")).unwrap_or(false) {
|
||||
app.input_path = path.to_string_lossy().to_string();
|
||||
if app.output_path.trim().is_empty() {
|
||||
if let Ok(out) = derive_output_path(&path) {
|
||||
app.output_path = out.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pack_files(stub_path: &Path, payload_path: &Path, output_path: &Path, config: &[u8]) -> Result<(), String> {
|
||||
let stub = fs::read(stub_path).map_err(|e| format!("读取 stub 失败: {e}"))?;
|
||||
let payload = fs::read(payload_path).map_err(|e| format!("读取 EXE 失败: {e}"))?;
|
||||
|
||||
let mut out = fs::File::create(output_path).map_err(|e| format!("创建输出失败: {e}"))?;
|
||||
out.write_all(&stub).map_err(|e| format!("写入 stub 失败: {e}"))?;
|
||||
out.write_all(&payload).map_err(|e| format!("写入 EXE 失败: {e}"))?;
|
||||
out.write_all(config).map_err(|e| format!("写入配置失败: {e}"))?;
|
||||
out.write_all(MAGIC).map_err(|e| format!("写入标识失败: {e}"))?;
|
||||
out.write_all(&(payload.len() as u64).to_le_bytes())
|
||||
.map_err(|e| format!("写入长度失败: {e}"))?;
|
||||
out.write_all(&(config.len() as u64).to_le_bytes())
|
||||
.map_err(|e| format!("写入长度失败: {e}"))?;
|
||||
out.flush().map_err(|e| format!("写入失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_api_base(value: &str) -> Result<String, String> {
|
||||
let trimmed = value.trim().trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
return Err("请输入授权系统地址".to_string());
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn derive_output_path(input: &Path) -> Result<PathBuf, String> {
|
||||
let parent = input.parent().unwrap_or(Path::new("."));
|
||||
let stem = input
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("app");
|
||||
let ext = input
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("exe");
|
||||
let name = format!("{stem}_packed.{ext}");
|
||||
Ok(parent.join(name))
|
||||
}
|
||||
|
||||
fn decode_integration_code(code: &str) -> Result<(String, String), String> {
|
||||
let trimmed = code.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("请输入对接码".to_string());
|
||||
}
|
||||
|
||||
if trimmed.contains('|') {
|
||||
return split_code(trimmed);
|
||||
}
|
||||
|
||||
let raw = trimmed.strip_prefix("LSC1.").unwrap_or(trimmed);
|
||||
let decoded = STANDARD.decode(raw).map_err(|_| "对接码格式错误".to_string())?;
|
||||
let decoded_str = String::from_utf8(decoded).map_err(|_| "对接码解析失败".to_string())?;
|
||||
split_code(&decoded_str)
|
||||
}
|
||||
|
||||
fn split_code(value: &str) -> Result<(String, String), String> {
|
||||
let mut parts = value.split('|');
|
||||
let project_id = parts.next().unwrap_or("").trim();
|
||||
let project_key = parts.next().unwrap_or("").trim();
|
||||
if project_id.is_empty() || project_key.is_empty() {
|
||||
return Err("对接码内容不完整".to_string());
|
||||
}
|
||||
Ok((project_id.to_string(), project_key.to_string()))
|
||||
}
|
||||
97
license-system-launcher/src/bin/launcher_pack.rs
Normal file
97
license-system-launcher/src/bin/launcher_pack.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
const MAGIC: &[u8] = b"LSWRAP1";
|
||||
|
||||
fn main() {
|
||||
if let Err(err) = run() {
|
||||
eprintln!("pack failed: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let args = parse_args()?;
|
||||
let stub = fs::read(&args.stub).map_err(|e| format!("read stub: {e}"))?;
|
||||
let payload = fs::read(&args.input).map_err(|e| format!("read input: {e}"))?;
|
||||
|
||||
let mut config_value: serde_json::Value = {
|
||||
let raw = fs::read(&args.config).map_err(|e| format!("read config: {e}"))?;
|
||||
serde_json::from_slice(&raw).map_err(|e| format!("invalid config json: {e}"))?
|
||||
};
|
||||
|
||||
let payload_name = Path::new(&args.input)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("payload.exe")
|
||||
.to_string();
|
||||
|
||||
let obj = config_value
|
||||
.as_object_mut()
|
||||
.ok_or("config must be a json object")?;
|
||||
let existing = obj.get("payload_name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if existing.trim().is_empty() {
|
||||
obj.insert("payload_name".to_string(), serde_json::Value::String(payload_name));
|
||||
}
|
||||
|
||||
let config = serde_json::to_vec(&config_value).map_err(|e| format!("serialize config: {e}"))?;
|
||||
|
||||
let payload_len = payload.len() as u64;
|
||||
let config_len = config.len() as u64;
|
||||
|
||||
let mut out = fs::File::create(&args.output).map_err(|e| format!("create output: {e}"))?;
|
||||
out.write_all(&stub).map_err(|e| format!("write stub: {e}"))?;
|
||||
out.write_all(&payload).map_err(|e| format!("write payload: {e}"))?;
|
||||
out.write_all(&config).map_err(|e| format!("write config: {e}"))?;
|
||||
out.write_all(MAGIC).map_err(|e| format!("write magic: {e}"))?;
|
||||
out.write_all(&payload_len.to_le_bytes())
|
||||
.map_err(|e| format!("write payload len: {e}"))?;
|
||||
out.write_all(&config_len.to_le_bytes())
|
||||
.map_err(|e| format!("write config len: {e}"))?;
|
||||
out.flush().map_err(|e| format!("flush output: {e}"))?;
|
||||
|
||||
println!("packed: {}", args.output);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Args {
|
||||
stub: String,
|
||||
input: String,
|
||||
config: String,
|
||||
output: String,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut stub = None;
|
||||
let mut input = None;
|
||||
let mut config = None;
|
||||
let mut output = None;
|
||||
|
||||
let mut iter = env::args().skip(1);
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--stub" | "-s" => stub = iter.next(),
|
||||
"--input" | "-i" => input = iter.next(),
|
||||
"--config" | "-c" => config = iter.next(),
|
||||
"--output" | "-o" => output = iter.next(),
|
||||
"--help" | "-h" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => return Err(format!("unknown arg: {arg}")),
|
||||
}
|
||||
}
|
||||
|
||||
let stub = stub.ok_or("missing --stub")?;
|
||||
let input = input.ok_or("missing --input")?;
|
||||
let config = config.ok_or("missing --config")?;
|
||||
let output = output.ok_or("missing --output")?;
|
||||
|
||||
Ok(Args { stub, input, config, output })
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!("Usage: launcher_pack --stub <launcher_stub.exe> --input <app.exe> --config <config.json> --output <wrapped.exe>");
|
||||
}
|
||||
443
license-system-launcher/src/bin/launcher_stub.rs
Normal file
443
license-system-launcher/src/bin/launcher_stub.rs
Normal file
@@ -0,0 +1,443 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::Rng;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const MAGIC: &[u8] = b"LSWRAP1";
|
||||
const FOOTER_LEN: usize = 7 + 8 + 8;
|
||||
|
||||
fn main() {
|
||||
if let Err(err) = run() {
|
||||
eprintln!("launcher error: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let exe_path = env::current_exe().map_err(|e| format!("current_exe: {e}"))?;
|
||||
let exe_dir = exe_path.parent().unwrap_or(Path::new(".")).to_path_buf();
|
||||
let (payload, mut config) = read_embedded(&exe_path)?;
|
||||
normalize_config(&mut config)?;
|
||||
|
||||
let card_key = load_card_key(&exe_dir, &config)?;
|
||||
let device_id = resolve_device_id();
|
||||
|
||||
let timeout = config.request_timeout_sec.unwrap_or(10);
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.build()
|
||||
.map_err(|e| format!("http client: {e}"))?;
|
||||
|
||||
let verify = verify_online(&client, &config, &card_key, &device_id)?;
|
||||
let access_token = verify.access_token.ok_or("missing access_token")?;
|
||||
let heartbeat_interval = if verify.heartbeat_interval > 0 {
|
||||
verify.heartbeat_interval as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let payload_path = extract_payload(&payload, &config, &exe_dir)?;
|
||||
|
||||
let mut child = Command::new(&payload_path)
|
||||
.current_dir(&exe_dir)
|
||||
.args(env::args().skip(1))
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn payload: {e}"))?;
|
||||
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let heartbeat_failed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
if heartbeat_interval > 0 {
|
||||
let client = client.clone();
|
||||
let config = config.clone();
|
||||
let device_id = device_id.clone();
|
||||
let access_token = access_token.clone();
|
||||
let stop_flag = stop.clone();
|
||||
let fail_flag = heartbeat_failed.clone();
|
||||
thread::spawn(move || {
|
||||
heartbeat_loop(&client, &config, &device_id, &access_token, heartbeat_interval, stop_flag, fail_flag);
|
||||
});
|
||||
}
|
||||
|
||||
loop {
|
||||
if heartbeat_failed.load(Ordering::SeqCst) {
|
||||
let _ = child.kill();
|
||||
break;
|
||||
}
|
||||
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => thread::sleep(Duration::from_millis(500)),
|
||||
Err(err) => {
|
||||
eprintln!("wait child failed: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
|
||||
let keep_payload = config.keep_payload.unwrap_or(false);
|
||||
let extract_to = config.extract_to.clone().unwrap_or_else(|| "temp".to_string());
|
||||
if !keep_payload || extract_to == "temp" {
|
||||
let _ = fs::remove_file(&payload_path);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LauncherConfig {
|
||||
api_base: String,
|
||||
project_id: String,
|
||||
project_secret: String,
|
||||
client_version: Option<String>,
|
||||
license_file: Option<String>,
|
||||
card_key: Option<String>,
|
||||
request_timeout_sec: Option<u64>,
|
||||
heartbeat_retries: Option<u32>,
|
||||
heartbeat_retry_delay_sec: Option<u64>,
|
||||
extract_to: Option<String>,
|
||||
payload_name: Option<String>,
|
||||
keep_payload: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ApiResponse<T> {
|
||||
code: i32,
|
||||
message: String,
|
||||
data: Option<T>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VerifyData {
|
||||
valid: bool,
|
||||
access_token: Option<String>,
|
||||
heartbeat_interval: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VerifyRequest<'a> {
|
||||
project_id: &'a str,
|
||||
key_code: &'a str,
|
||||
device_id: &'a str,
|
||||
client_version: Option<&'a str>,
|
||||
timestamp: i64,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HeartbeatRequest<'a> {
|
||||
access_token: &'a str,
|
||||
device_id: &'a str,
|
||||
timestamp: i64,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
struct VerifyResult {
|
||||
access_token: Option<String>,
|
||||
heartbeat_interval: i64,
|
||||
}
|
||||
|
||||
fn read_embedded(exe_path: &Path) -> Result<(Vec<u8>, LauncherConfig), String> {
|
||||
let bytes = fs::read(exe_path).map_err(|e| format!("read exe: {e}"))?;
|
||||
if bytes.len() < FOOTER_LEN {
|
||||
return Err("invalid launcher file".to_string());
|
||||
}
|
||||
|
||||
let footer_offset = bytes.len() - FOOTER_LEN;
|
||||
let magic = &bytes[footer_offset..footer_offset + MAGIC.len()];
|
||||
if magic != MAGIC {
|
||||
return Err("missing embedded payload".to_string());
|
||||
}
|
||||
|
||||
let payload_len = read_u64(&bytes[footer_offset + MAGIC.len()..footer_offset + MAGIC.len() + 8])? as usize;
|
||||
let config_len = read_u64(&bytes[footer_offset + MAGIC.len() + 8..footer_offset + MAGIC.len() + 16])? as usize;
|
||||
|
||||
let total_len = payload_len + config_len + FOOTER_LEN;
|
||||
if total_len > bytes.len() {
|
||||
return Err("embedded data corrupted".to_string());
|
||||
}
|
||||
|
||||
let payload_start = bytes.len() - total_len;
|
||||
let payload_end = payload_start + payload_len;
|
||||
let config_end = payload_end + config_len;
|
||||
|
||||
let payload = bytes[payload_start..payload_end].to_vec();
|
||||
let config_bytes = &bytes[payload_end..config_end];
|
||||
let config: LauncherConfig = serde_json::from_slice(config_bytes)
|
||||
.map_err(|e| format!("parse config: {e}"))?;
|
||||
|
||||
Ok((payload, config))
|
||||
}
|
||||
|
||||
fn read_u64(slice: &[u8]) -> Result<u64, String> {
|
||||
if slice.len() != 8 {
|
||||
return Err("invalid footer".to_string());
|
||||
}
|
||||
let mut buf = [0u8; 8];
|
||||
buf.copy_from_slice(slice);
|
||||
Ok(u64::from_le_bytes(buf))
|
||||
}
|
||||
|
||||
fn normalize_config(config: &mut LauncherConfig) -> Result<(), String> {
|
||||
if config.api_base.trim().is_empty() {
|
||||
return Err("api_base is empty".to_string());
|
||||
}
|
||||
config.api_base = config.api_base.trim_end_matches('/').to_string();
|
||||
|
||||
if config.project_id.trim().is_empty() {
|
||||
return Err("project_id is empty".to_string());
|
||||
}
|
||||
if config.project_secret.trim().is_empty() {
|
||||
return Err("project_secret is empty".to_string());
|
||||
}
|
||||
|
||||
if config.request_timeout_sec.is_none() {
|
||||
config.request_timeout_sec = Some(10);
|
||||
}
|
||||
if config.heartbeat_retries.is_none() {
|
||||
config.heartbeat_retries = Some(2);
|
||||
}
|
||||
if config.heartbeat_retry_delay_sec.is_none() {
|
||||
config.heartbeat_retry_delay_sec = Some(5);
|
||||
}
|
||||
if let Some(target) = config.extract_to.as_ref() {
|
||||
config.extract_to = Some(target.trim().to_lowercase());
|
||||
} else {
|
||||
config.extract_to = Some("temp".to_string());
|
||||
}
|
||||
if config.payload_name.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
|
||||
config.payload_name = Some("payload.exe".to_string());
|
||||
}
|
||||
if config.keep_payload.is_none() {
|
||||
config.keep_payload = Some(false);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_card_key(exe_dir: &Path, config: &LauncherConfig) -> Result<String, String> {
|
||||
if let Some(card) = config.card_key.as_ref().filter(|s| !s.trim().is_empty()) {
|
||||
return Ok(card.trim().to_string());
|
||||
}
|
||||
|
||||
let license_file = config.license_file.clone().unwrap_or_else(|| "license.key".to_string());
|
||||
let path = exe_dir.join(license_file);
|
||||
let content = fs::read_to_string(&path).map_err(|_| "missing license.key".to_string())?;
|
||||
let card = content
|
||||
.lines()
|
||||
.map(|line| line.trim())
|
||||
.find(|line| !line.is_empty())
|
||||
.ok_or("license.key is empty".to_string())?;
|
||||
Ok(card.to_string())
|
||||
}
|
||||
|
||||
fn resolve_device_id() -> String {
|
||||
let mac = mac_address::get_mac_address()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|m| m.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let ip = local_ip_address::local_ip()
|
||||
.ok()
|
||||
.map(|addr| addr.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let raw = format!("{mac}|{ip}");
|
||||
if raw.len() <= 64 {
|
||||
raw
|
||||
} else {
|
||||
sha256_hex(raw.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_online(client: &Client, config: &LauncherConfig, card_key: &str, device_id: &str) -> Result<VerifyResult, String> {
|
||||
let timestamp = unix_ts();
|
||||
let payload = format!("{}|{}|{}", config.project_id, device_id, timestamp);
|
||||
let signature = sign_hmac(&payload, &config.project_secret);
|
||||
|
||||
let request = VerifyRequest {
|
||||
project_id: &config.project_id,
|
||||
key_code: card_key,
|
||||
device_id,
|
||||
client_version: config.client_version.as_deref(),
|
||||
timestamp,
|
||||
signature,
|
||||
};
|
||||
|
||||
let url = format!("{}/api/auth/verify", config.api_base);
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Device-Id", device_id)
|
||||
.json(&request)
|
||||
.send()
|
||||
.map_err(|e| format!("verify request: {e}"))?;
|
||||
|
||||
let text = resp.text().map_err(|e| format!("verify response: {e}"))?;
|
||||
let api: ApiResponse<VerifyData> = serde_json::from_str(&text)
|
||||
.map_err(|e| format!("verify decode: {e} ({text})"))?;
|
||||
|
||||
if api.code != 200 {
|
||||
return Err(format!("verify failed: {}", api.message));
|
||||
}
|
||||
|
||||
let data = api.data.ok_or("verify missing data".to_string())?;
|
||||
if !data.valid {
|
||||
return Err("license invalid".to_string());
|
||||
}
|
||||
|
||||
Ok(VerifyResult {
|
||||
access_token: data.access_token,
|
||||
heartbeat_interval: data.heartbeat_interval as i64,
|
||||
})
|
||||
}
|
||||
|
||||
fn heartbeat_loop(client: &Client, config: &LauncherConfig, device_id: &str, access_token: &str, interval: u64, stop: Arc<AtomicBool>, failed: Arc<AtomicBool>) {
|
||||
let retries = config.heartbeat_retries.unwrap_or(2);
|
||||
let delay = config.heartbeat_retry_delay_sec.unwrap_or(5);
|
||||
|
||||
loop {
|
||||
for _ in 0..interval {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
|
||||
let mut ok = false;
|
||||
for attempt in 0..=retries {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
match send_heartbeat(client, config, device_id, access_token) {
|
||||
Ok(_) => {
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
Err(_) if attempt < retries => {
|
||||
thread::sleep(Duration::from_secs(delay));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
failed.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_heartbeat(client: &Client, config: &LauncherConfig, device_id: &str, access_token: &str) -> Result<(), String> {
|
||||
let timestamp = unix_ts();
|
||||
let payload = format!("{}|{}|{}", config.project_id, device_id, timestamp);
|
||||
let signature = sign_hmac(&payload, &config.project_secret);
|
||||
|
||||
let request = HeartbeatRequest {
|
||||
access_token,
|
||||
device_id,
|
||||
timestamp,
|
||||
signature,
|
||||
};
|
||||
|
||||
let url = format!("{}/api/auth/heartbeat", config.api_base);
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Device-Id", device_id)
|
||||
.json(&request)
|
||||
.send()
|
||||
.map_err(|e| format!("heartbeat request: {e}"))?;
|
||||
|
||||
let text = resp.text().map_err(|e| format!("heartbeat response: {e}"))?;
|
||||
let api: ApiResponse<serde_json::Value> = serde_json::from_str(&text)
|
||||
.map_err(|e| format!("heartbeat decode: {e} ({text})"))?;
|
||||
|
||||
if api.code != 200 {
|
||||
return Err(format!("heartbeat failed: {}", api.message));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_payload(payload: &[u8], config: &LauncherConfig, exe_dir: &Path) -> Result<PathBuf, String> {
|
||||
let payload_name = config.payload_name.clone().unwrap_or_else(|| "payload.exe".to_string());
|
||||
let safe_name = Path::new(&payload_name)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("payload.exe")
|
||||
.to_string();
|
||||
|
||||
let extract_to = config.extract_to.as_deref().unwrap_or("temp").to_lowercase();
|
||||
let target_path = if extract_to == "self" {
|
||||
exe_dir.join(safe_name)
|
||||
} else {
|
||||
let mut rng = rand::thread_rng();
|
||||
let suffix: u32 = rng.gen();
|
||||
let (stem, ext) = split_name(&safe_name);
|
||||
let file_name = if ext.is_empty() {
|
||||
format!("{stem}_{suffix}")
|
||||
} else {
|
||||
format!("{stem}_{suffix}.{ext}")
|
||||
};
|
||||
env::temp_dir().join(file_name)
|
||||
};
|
||||
|
||||
fs::write(&target_path, payload).map_err(|e| format!("write payload: {e}"))?;
|
||||
Ok(target_path)
|
||||
}
|
||||
|
||||
fn split_name(name: &str) -> (String, String) {
|
||||
let path = Path::new(name);
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("payload")
|
||||
.to_string();
|
||||
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_string();
|
||||
(stem, ext)
|
||||
}
|
||||
|
||||
fn unix_ts() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_else(|_| Duration::from_secs(0))
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
fn sign_hmac(payload: &str, secret: &str) -> String {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
|
||||
.expect("hmac can take key of any size");
|
||||
mac.update(payload.as_bytes());
|
||||
let result = mac.finalize().into_bytes();
|
||||
to_hex_lower(&result)
|
||||
}
|
||||
|
||||
fn to_hex_lower(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
out.push_str(&format!("{:02x}", b));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sha256_hex(data: &[u8]) -> String {
|
||||
let hash = Sha256::digest(data);
|
||||
to_hex_lower(&hash)
|
||||
}
|
||||
Reference in New Issue
Block a user