telegram free connection

This commit is contained in:
2026-05-02 18:11:05 +07:00
parent f836da06d5
commit 6de8968f11
20 changed files with 428 additions and 114 deletions
@@ -2,8 +2,6 @@ package app.netrunner.vpn
import android.app.*
import android.content.*
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.VpnService
import android.os.*
import android.util.Log
@@ -42,14 +40,14 @@ data class TunnelConfig @JsonCreator constructor(
@JsonProperty("mtu") val mtu: Int,
@JsonProperty("killswitchEnabled") val killswitchEnabled: Boolean,
@JsonProperty("excludedApps") val excludedApps: List<String>,
@JsonProperty("excludedDomains") val excludedDomains: List<String>
@JsonProperty("excludedDomains") val excludedDomains: List<String>,
// Новое поле для режима песочницы
@JsonProperty("allowedApps") val allowedApps: List<String>? = null
) : Parcelable
class TunVpnService : VpnService() {
private var activeSession: Session? = null
private var vpnInterface: ParcelFileDescriptor? = null
// ДОБАВЛЕНО: Корутина для статистики живет теперь здесь
private var statsJob: Job? = null
companion object {
@@ -81,25 +79,41 @@ class TunVpnService : VpnService() {
val builder = Builder().addAddress(config.address, config.prefix)
.addDnsServer(config.dns).addRoute("0.0.0.0", 0).setMtu(config.mtu)
for (appPkg in config.excludedApps) {
if (appPkg.isNotBlank()) {
try {
builder.addDisallowedApplication(appPkg.trim())
Log.d(TAG, "Excluded app from VPN tunnel: $appPkg")
} catch (e: Exception) {
Log.w(TAG, "Failed to exclude app $appPkg (possibly not installed)", e)
val allowed = config.allowedApps ?: emptyList()
// ЛОГИКА ФИЛЬТРАЦИИ
if (allowed.isNotEmpty()) {
// Режим песочницы (Allow-list) - только для Telegram
for (appPkg in allowed) {
if (appPkg.isNotBlank()) {
try {
builder.addAllowedApplication(appPkg.trim())
Log.d(TAG, "Allowed app in VPN tunnel: $appPkg")
} catch (e: Exception) {
Log.w(TAG, "Failed to allow app $appPkg (possibly not installed)", e)
}
}
}
} else {
// Обычный режим (Exclude-list)
for (appPkg in config.excludedApps) {
if (appPkg.isNotBlank()) {
try {
builder.addDisallowedApplication(appPkg.trim())
Log.d(TAG, "Excluded app from VPN tunnel: $appPkg")
} catch (e: Exception) {
Log.w(TAG, "Failed to exclude app $appPkg (possibly not installed)", e)
}
}
}
try { builder.addDisallowedApplication(packageName) } catch (e: Exception) { Log.e(TAG, "Exclude fail") }
}
try { builder.addDisallowedApplication(packageName) } catch (e: Exception) { Log.e(TAG, "Exclude fail") }
vpnInterface = builder.establish() ?: return
val fd = vpnInterface!!.detachFd()
try {
val manager = SessionManager()
// Передаем параметры в ядро Rust
activeSession = manager.spawnSession(
"$ip:$port",
fd,
@@ -110,7 +124,6 @@ class TunVpnService : VpnService() {
)
triggerVibration(true)
sendStatusBroadcast("connected")
startStatsLoop()
} catch (e: Exception) {
Log.e(TAG, "Rust fail", e)
@@ -119,8 +132,7 @@ class TunVpnService : VpnService() {
}
private fun stopServiceProperly() {
stopStatsLoop() // ДОБАВЛЕНО: Останавливаем цикл при дисконнекте
stopStatsLoop()
activeSession?.stop()
activeSession = null
try { vpnInterface?.close() } catch (e: Exception) { }
@@ -131,10 +143,8 @@ class TunVpnService : VpnService() {
stopSelf()
}
// --- НОВЫЙ БЛОК КОРУТИН ВНУТРИ СЕРВИСА ---
private fun startStatsLoop() {
if (statsJob?.isActive == true) return
Log.d(TAG, "Starting traffic stats loop IN SERVICE")
statsJob = CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
@@ -165,10 +175,9 @@ class TunVpnService : VpnService() {
putExtra("txBytes", txBytes)
putExtra("rxPackets", rxPackets)
putExtra("txPackets", txPackets)
setPackage(packageName) // Безопасность: шлем только в свое приложение
setPackage(packageName)
})
}
// -----------------------------------------
private fun sendStatusBroadcast(status: String) {
sendBroadcast(Intent("com.netrunner.vpn.STATUS_UPDATE").apply {
@@ -217,6 +226,7 @@ class TunVpnService : VpnService() {
Notification.Builder(this)
}
// ВОЗВРАЩЕНО ТВОЕ КАСТОМНОЕ УВЕДОМЛЕНИЕ!
val vpnBlack = 0xFF121212.toInt()
val notification = builder
@@ -248,30 +258,30 @@ class TunVpnService : VpnService() {
getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
if (!vibrator.hasVibrator()) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val fixedDelay = 30L
val stepCount = 23
val timings = LongArray(stepCount * 2)
val amplitudes = IntArray(stepCount * 2)
val timings: LongArray
val amplitudes: IntArray
for (i in 0 until stepCount) {
val progress = i.toFloat() / (stepCount - 1)
val activeTime = if (isAscending) (10 + (progress * 50)).toLong() else (60 - (progress * 50)).toLong()
timings[i * 2] = fixedDelay
timings[i * 2 + 1] = activeTime
amplitudes[i * 2] = 0
amplitudes[i * 2 + 1] = 255
if (isAscending) {
timings = longArrayOf(0, 30, 40, 40, 30, 50, 20, 70)
amplitudes = intArrayOf(0, 40, 0, 80, 0, 150, 0, 255)
} else {
timings = longArrayOf(0, 50, 30, 30, 40, 20)
amplitudes = intArrayOf(0, 255, 0, 100, 0, 30)
}
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
val effect = VibrationEffect.createWaveform(timings, amplitudes, -1)
vibrator.vibrate(effect)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(1500)
vibrator.vibrate(if (isAscending) 1200L else 300L)
}
}
override fun onDestroy() {
stopStatsLoop() // На всякий случай
stopStatsLoop()
super.onDestroy()
}
}
@@ -280,7 +290,6 @@ class TunVpnService : VpnService() {
class VpnPlugin(private val activity: Activity) : Plugin(activity) {
private var pendingArgs: VpnArgs? = null
// ДОБАВЛЕНО: Теперь плагин слушает ДВА типа бродкастов (статус и трафик)
private val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
@@ -294,7 +303,6 @@ class VpnPlugin(private val activity: Activity) : Plugin(activity) {
put("rxPackets", intent.getLongExtra("rxPackets", 0L))
put("txPackets", intent.getLongExtra("txPackets", 0L))
}
// Пересылаем в UI
trigger("traffic-stats", result)
}
}
@@ -302,7 +310,7 @@ class VpnPlugin(private val activity: Activity) : Plugin(activity) {
}
private fun requestNotificationPermission() {
if (android.os.Build.VERSION.SDK_INT >= 33) {
if (Build.VERSION.SDK_INT >= 33) {
val permission = "android.permission.POST_NOTIFICATIONS"
if (androidx.core.content.ContextCompat.checkSelfPermission(activity, permission) !=
android.content.pm.PackageManager.PERMISSION_GRANTED) {
@@ -311,6 +319,7 @@ class VpnPlugin(private val activity: Activity) : Plugin(activity) {
}
}
// ВОЗВРАЩЕНО ТВОЕ ЛОГИРОВАНИЕ СКАЧИВАНИЯ БЛОКЛИСТА
private fun updateBlocklistIfNeeded() {
try {
val cachePath = activity.cacheDir.absolutePath
@@ -341,7 +350,6 @@ class VpnPlugin(private val activity: Activity) : Plugin(activity) {
requestNotificationPermission()
// ДОБАВЛЕНО: Регистрируем фильтр на оба действия
val filter = IntentFilter().apply {
addAction("com.netrunner.vpn.STATUS_UPDATE")
addAction("com.netrunner.vpn.TRAFFIC_STATS_UPDATE")
@@ -356,7 +364,7 @@ class VpnPlugin(private val activity: Activity) : Plugin(activity) {
CoroutineScope(Dispatchers.IO).launch { updateBlocklistIfNeeded() }
CoroutineScope(Dispatchers.Main).launch {
kotlinx.coroutines.delay(500)
delay(500)
val currentStatus = getHardwareVpnStatus()
updateStatus(currentStatus)
Log.d(TAG, "Plugin reloaded. Current status: $currentStatus")
@@ -397,7 +405,10 @@ class VpnPlugin(private val activity: Activity) : Plugin(activity) {
private fun startVpnService(args: VpnArgs) {
val intent = Intent(activity, TunVpnService::class.java).apply {
action = "START_VPN"; putExtra("ip", args.ip); putExtra("port", args.port); putExtra("config", args.config)
action = "START_VPN"
putExtra("ip", args.ip)
putExtra("port", args.port)
putExtra("config", args.config)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) activity.startForegroundService(intent)
else activity.startService(intent)
@@ -422,45 +433,54 @@ class VpnPlugin(private val activity: Activity) : Plugin(activity) {
invoke.resolve(JSObject().apply { put("status", status) })
}
// ВОЗВРАЩЕНО ТВОЕ ЛОГИРОВАНИЕ ПРИ ПОИСКЕ ПРИЛОЖЕНИЙ
@Command
fun getInstalledApps(invoke: Invoke) {
val pm = activity.packageManager
// Используем флаг GET_META_DATA для более полной информации
val packages = pm.getInstalledPackages(0)
val jsArray = JSArray()
for (pkg in packages) {
val info = pkg.applicationInfo ?: continue
// Условие 1: Это не наше собственное приложение
if (pkg.packageName == activity.packageName) continue
// Условие 2: Это пользовательское приложение ИЛИ оно имеет иконку в лаунчере
val isSystem = (info.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0
val hasLauncher = pm.getLaunchIntentForPackage(pkg.packageName) != null
// Если это не системное приложение (установлено юзером) ИЛИ у него есть лаунчер
if (!isSystem || hasLauncher) {
val appName = info.loadLabel(pm).toString()
val obj = JSObject()
obj.put("appName", appName)
obj.put("packageName", pkg.packageName)
jsArray.put(obj)
}
}
Log.d(TAG, "Found ${jsArray.length()} apps for exclusion list")
val result = JSObject()
result.put("apps", jsArray)
invoke.resolve(result)
}
// НОВАЯ ФУНКЦИЯ ДЛЯ ЗАПУСКА ТЕЛЕГИ ИЗ REACT
@Command
fun launchApp(invoke: Invoke) {
val args = invoke.parseArgs(LaunchAppArgs::class.java)
val launchIntent = activity.packageManager.getLaunchIntentForPackage(args.packageName)
if (launchIntent != null) {
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(launchIntent)
invoke.resolve()
} else {
invoke.reject("App not found")
}
}
private fun updateStatus(status: String) {
trigger("status-change", JSObject().apply { put("status", status) })
}
@InvokeArg class VpnArgs { var config: TunnelConfig? = null; var ip: String = ""; var port: Int = 0 }
@InvokeArg class LaunchAppArgs { var packageName: String = "" }
}
@@ -4,6 +4,10 @@ const COMMANDS: &[&str] = &[
"check_actual_vpn_status",
"registerListener",
"unregisterListener",
"get_installed_apps", // <--- ДОБАВЛЕНО
"launch_app", // <--- ДОБАВЛЕНО
"get_traffic_stats", // <--- ДОБАВЛЕНО
"traffic_stats", // <--- На всякий случай (было в старых логах)
];
fn main() {
@@ -1,4 +1,5 @@
import { addPluginListener, invoke } from "@tauri-apps/api/core";
export interface TunnelConfig {
address: string;
prefix: number;
@@ -7,6 +8,7 @@ export interface TunnelConfig {
killswitchEnabled: boolean;
excludedApps: string[];
excludedDomains: string[];
allowedApps?: string[];
}
export interface TrafficStatsResponse {
@@ -22,11 +24,7 @@ export interface AppInfo {
}
export async function startVpn(config: TunnelConfig, ip: string, port: number) {
return await invoke("plugin:vpn|start_vpn", {
config,
ip,
port,
});
return await invoke("plugin:vpn|start_vpn", { config, ip, port });
}
export async function stopVpn() {
@@ -35,21 +33,17 @@ export async function stopVpn() {
export async function checkActualVpnStatus(): Promise<"connected" | "idle"> {
try {
console.log("Actual vpn status called");
const result = await invoke<{ status: string }>(
"plugin:vpn|check_actual_vpn_status",
);
console.log(result);
return result.status as "connected" | "idle";
} catch (e) {
console.error("Failed to check VPN status", e);
return "idle";
}
}
export const setupVpnStatusListener = async (callback: (p: any) => void) => {
return await addPluginListener("vpn", "status-change", (payload) => {
console.log(">>> [SUCCESS] Пришло через мобильный листенер:", payload);
callback(payload);
});
};
@@ -58,12 +52,15 @@ export const setupTrafficStatsListener = async (
callback: (stats: TrafficStatsResponse) => void,
) => {
return await addPluginListener("vpn", "traffic-stats", (payload) => {
console.log(">>> [SUCCESS] Пришло через мобильный листенер:", payload);
callback(payload as TrafficStatsResponse);
});
};
export async function getInstalledApps(): Promise<AppInfo[]> {
// Rust теперь сам распаковывает "apps" и отдает нам готовый массив
return await invoke<AppInfo[]>("plugin:vpn|get_installed_apps");
}
// НОВАЯ ФУНКЦИЯ ДЛЯ ЗАПУСКА ТЕЛЕГИ
export async function launchApp(packageName: string): Promise<void> {
return await invoke("plugin:vpn|launch_app", { packageName });
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-installed-apps"
description = "Enables the get_installed_apps command without any pre-configured scope."
commands.allow = ["get_installed_apps"]
[[permission]]
identifier = "deny-get-installed-apps"
description = "Denies the get_installed_apps command without any pre-configured scope."
commands.deny = ["get_installed_apps"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-launch-app"
description = "Enables the launch_app command without any pre-configured scope."
commands.allow = ["launch_app"]
[[permission]]
identifier = "deny-launch-app"
description = "Denies the launch_app command without any pre-configured scope."
commands.deny = ["launch_app"]
@@ -36,6 +36,32 @@ Denies the check_actual_vpn_status command without any pre-configured scope.
<tr>
<td>
`vpn:allow-get-installed-apps`
</td>
<td>
Enables the get_installed_apps command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vpn:deny-get-installed-apps`
</td>
<td>
Denies the get_installed_apps command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vpn:allow-get-traffic-stats`
</td>
@@ -62,6 +88,32 @@ Denies the get_traffic_stats command without any pre-configured scope.
<tr>
<td>
`vpn:allow-launch-app`
</td>
<td>
Enables the launch_app command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vpn:deny-launch-app`
</td>
<td>
Denies the launch_app command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vpn:allow-registerListener`
</td>
@@ -3,7 +3,7 @@ identifier = "allow-vpn-control"
description = "Allows Vpn Control"
[permission.commands]
allow = ["start_vpn", "stop_vpn", "get_traffic_stats", "check_actual_vpn_status", "registerListener", "unregisterListener", "get_installed_apps"]
allow = ["start_vpn", "stop_vpn", "get_traffic_stats", "check_actual_vpn_status", "registerListener", "unregisterListener", "get_installed_apps", "launch_app"]
[permission.events]
allow = ["status-change"]
@@ -306,6 +306,18 @@
"const": "deny-check-actual-vpn-status",
"markdownDescription": "Denies the check_actual_vpn_status command without any pre-configured scope."
},
{
"description": "Enables the get_installed_apps command without any pre-configured scope.",
"type": "string",
"const": "allow-get-installed-apps",
"markdownDescription": "Enables the get_installed_apps command without any pre-configured scope."
},
{
"description": "Denies the get_installed_apps command without any pre-configured scope.",
"type": "string",
"const": "deny-get-installed-apps",
"markdownDescription": "Denies the get_installed_apps command without any pre-configured scope."
},
{
"description": "Enables the get_traffic_stats command without any pre-configured scope.",
"type": "string",
@@ -318,6 +330,18 @@
"const": "deny-get-traffic-stats",
"markdownDescription": "Denies the get_traffic_stats command without any pre-configured scope."
},
{
"description": "Enables the launch_app command without any pre-configured scope.",
"type": "string",
"const": "allow-launch-app",
"markdownDescription": "Enables the launch_app command without any pre-configured scope."
},
{
"description": "Denies the launch_app command without any pre-configured scope.",
"type": "string",
"const": "deny-launch-app",
"markdownDescription": "Denies the launch_app command without any pre-configured scope."
},
{
"description": "Enables the registerListener command without any pre-configured scope.",
"type": "string",
@@ -1,6 +1,5 @@
use std::sync::atomic::Ordering;
use crate::{models::*, Vpn};
use std::sync::atomic::Ordering;
use tauri::{command, AppHandle, Manager, Runtime};
#[command]
@@ -11,23 +10,16 @@ pub(crate) fn start_vpn<R: Runtime>(
port: i32,
) -> crate::Result<()> {
let vpn = app.state::<Vpn<R>>();
// Пытаемся захватить флаг
if vpn
.is_running
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
eprintln!("[DEBUG] VPN уже запущен, игнорирую вызов.");
return Ok(());
}
eprintln!("--- [DEBUG] STARTING VPN TUNNEL ---");
match vpn.start_vpn(config, ip, port) {
Ok(res) => Ok(res),
Err(e) => {
// При ошибке возвращаем флаг в false
vpn.is_running.store(false, Ordering::SeqCst);
Err(e)
}
@@ -37,14 +29,8 @@ pub(crate) fn start_vpn<R: Runtime>(
#[command]
pub(crate) fn stop_vpn<R: Runtime>(app: AppHandle<R>) -> crate::Result<()> {
let vpn = app.state::<Vpn<R>>();
// Останавливаем
let result = vpn.stop_vpn();
// Сбрасываем флаг, даже если ошибка при остановке (чтобы не залочить приложение)
vpn.is_running.store(false, Ordering::SeqCst);
eprintln!("--- [DEBUG] STOPPING VPN TUNNEL ---");
result
}
@@ -52,10 +38,7 @@ pub(crate) fn stop_vpn<R: Runtime>(app: AppHandle<R>) -> crate::Result<()> {
pub(crate) fn check_actual_vpn_status<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> crate::Result<StatusResponse> {
// StatusResponse должен быть в твоих моделях
let vpn = app.state::<Vpn<R>>();
// Вызываем метод из vpn_impl (в mobile это уйдет в Kotlin)
vpn.check_actual_vpn_status()
}
@@ -63,3 +46,8 @@ pub(crate) fn check_actual_vpn_status<R: tauri::Runtime>(
pub(crate) fn get_installed_apps<R: Runtime>(app: AppHandle<R>) -> crate::Result<Vec<AppInfo>> {
app.state::<Vpn<R>>().get_installed_apps()
}
#[command]
pub(crate) fn launch_app<R: Runtime>(app: AppHandle<R>, package_name: String) -> crate::Result<()> {
app.state::<Vpn<R>>().launch_app(package_name)
}
@@ -39,7 +39,7 @@ impl<R: Runtime> Clone for Vpn<R> {
}
impl<R: Runtime> VpnInterface<R> for Vpn<R> {
fn start_vpn(&self, _config: TunnelConfig, ip: String, port: i32) -> crate::Result<()> {
fn start_vpn(&self, config: TunnelConfig, ip: String, port: i32) -> crate::Result<()> {
let _ = self.app.emit(
"status-change",
StatusResponse {
@@ -132,4 +132,8 @@ impl<R: Runtime> VpnInterface<R> for Vpn<R> {
fn get_installed_apps(&self) -> crate::Result<Vec<AppInfo>> {
Ok(vec![]) // На десктопе пока отдаем пустоту
}
fn launch_app(&self, _package_name: String) -> crate::Result<()> {
Ok(()) // На десктопе мы блокируем это через UI, так что просто Ok
}
}
+3 -1
View File
@@ -23,6 +23,7 @@ pub trait VpnInterface<R: Runtime>: Send + Sync + 'static {
fn stop_vpn(&self) -> crate::Result<()>;
fn check_actual_vpn_status(&self) -> crate::Result<StatusResponse>;
fn get_installed_apps(&self) -> crate::Result<Vec<AppInfo>>;
fn launch_app(&self, package_name: String) -> crate::Result<()>;
}
pub struct Vpn<R: Runtime> {
inner: Box<dyn VpnInterface<R>>,
@@ -52,7 +53,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::start_vpn,
commands::stop_vpn,
commands::check_actual_vpn_status,
commands::get_installed_apps, // <--- НОВОЕ
commands::get_installed_apps,
commands::launch_app, // <--- НОВОЕ
])
.setup(|app, api| {
#[cfg(mobile)]
+10 -20
View File
@@ -35,41 +35,31 @@ impl<R: Runtime> VpnInterface<R> for Vpn<R> {
}
fn check_actual_vpn_status(&self) -> crate::Result<StatusResponse> {
println!(">>> [RUST PLUGIN] Calling Kotlin: checkActualVpnStatus");
self.0
.run_mobile_plugin::<StatusResponse>("checkActualVpnStatus", ())
.map(|res| {
println!(">>> [RUST PLUGIN] Success from Kotlin: {:?}", res);
res
})
.map_err(|e| {
println!(">>> [RUST PLUGIN] Critical Error calling Kotlin: {:?}", e);
let err_msg = format!("{:?}", e);
if err_msg.contains("CommandNotFound") {
println!(
"!!! [RUST PLUGIN] ДИАГНОЗ: Метод checkActualVpnStatus не найден в Kotlin."
);
}
crate::Error::from(e)
})
.map_err(crate::Error::from)
}
fn get_installed_apps(&self) -> crate::Result<Vec<AppInfo>> {
// Создаем временную структуру для распаковки JSObject, который шлет Kotlin
#[derive(serde::Deserialize)]
struct Wrapper {
apps: Vec<AppInfo>,
}
let wrapper: Wrapper = self
.0
.run_mobile_plugin("getInstalledApps", ())
.map_err(crate::error::Error::from)?;
// Отдаем наружу (в React) чистый массив
Ok(wrapper.apps)
}
fn launch_app(&self, package_name: String) -> crate::Result<()> {
#[derive(serde::Serialize)]
struct Payload {
#[serde(rename = "packageName")]
package_name: String,
}
self.send("launchApp", Payload { package_name })
}
}
impl<R: Runtime> Vpn<R> {
@@ -7,10 +7,11 @@ pub struct TunnelConfig {
pub prefix: i32,
pub dns: String,
pub mtu: i32,
// Новые параметры
pub killswitch_enabled: bool,
pub excluded_apps: Vec<String>,
pub excluded_domains: Vec<String>,
#[serde(default)]
pub allowed_apps: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize)]
@@ -31,7 +32,7 @@ pub struct StatusResponse {
}
#[derive(Serialize, Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")] // Чтобы в JS поля были rxBytes, txBytes и т.д.
#[serde(rename_all = "camelCase")]
pub struct TrafficStatsResponse {
pub rx_bytes: u64,
pub tx_bytes: u64,