diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f5d2709..720493c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,6 +12,7 @@ env: FILE_ROOTSHELL: ../../rootshell/rootshell FILE_RAYHUNTER_DAEMON_ORBIC: ../../rayhunter-daemon-orbic/rayhunter-daemon FILE_RAYHUNTER_DAEMON_TPLINK: ../../rayhunter-daemon-tplink/rayhunter-daemon + FILE_RAYHUNTER_DAEMON_WINGTECH: ../../rayhunter-daemon-wingtech/rayhunter-daemon jobs: files_changed: @@ -97,8 +98,9 @@ jobs: strategy: matrix: device: - - name: tplink - name: orbic + - name: tplink + - name: wingtech runs-on: ubuntu-latest permissions: contents: read @@ -208,8 +210,9 @@ jobs: strategy: matrix: device: - - name: tplink - name: orbic + - name: tplink + - name: wingtech runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index 42ab477..85326cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,17 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -348,6 +359,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64_light" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6aca08f76b8485947a20a1b3096e5a8cd6edbcecc6d2a8932df9b41d36aadf" + [[package]] name = "base64ct" version = "1.7.3" @@ -409,6 +426,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "built" version = "0.7.7" @@ -502,6 +528,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.38" @@ -1429,13 +1465,25 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "installer" version = "0.3.4" dependencies = [ "adb_client", + "aes", "anyhow", "axum", + "base64_light", + "block-padding", "bytes", "clap", "env_logger 0.11.8", diff --git a/bin/Cargo.toml b/bin/Cargo.toml index 7f2758c..41109e7 100644 --- a/bin/Cargo.toml +++ b/bin/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" # These feature flags are mutually exclusive, and exactly one must be enabled. orbic = ["rayhunter/orbic"] tplink = ["rayhunter/tplink"] +wingtech = ["rayhunter/wingtech"] default = ["orbic"] diff --git a/bin/src/display/mod.rs b/bin/src/display/mod.rs index 124ef71..cf7a3d2 100644 --- a/bin/src/display/mod.rs +++ b/bin/src/display/mod.rs @@ -15,14 +15,13 @@ mod orbic; #[cfg(feature = "orbic")] pub use orbic::update_ui; +#[cfg(feature = "wingtech")] +mod wingtech; +#[cfg(feature = "wingtech")] +pub use wingtech::update_ui; + pub enum DisplayState { Recording, Paused, WarningDetected, } - -#[cfg(all(feature = "orbic", feature = "tplink"))] -compile_error!("cannot compile for many devices at once"); - -#[cfg(not(any(feature = "orbic", feature = "tplink")))] -compile_error!("cannot compile for no device at all"); diff --git a/bin/src/display/wingtech.rs b/bin/src/display/wingtech.rs new file mode 100644 index 0000000..be228cb --- /dev/null +++ b/bin/src/display/wingtech.rs @@ -0,0 +1,54 @@ +/// Display support for the Wingtech CT2MHS01 hotspot. +/// +/// Tested on (from `/etc/wt_version`): +/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP +/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55 +/// WT_HARDWARE_VERSION=89323_1_20 +use crate::config; +use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer}; +use crate::display::DisplayState; + +use tokio::sync::mpsc::Receiver; +use tokio::sync::oneshot; +use tokio_util::task::TaskTracker; + +const FB_PATH: &str = "/dev/fb0"; + +#[derive(Copy, Clone, Default)] +struct Framebuffer; + +impl GenericFramebuffer for Framebuffer { + fn dimensions(&self) -> Dimensions { + Dimensions { + height: 128, + width: 160, + } + } + + fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) { + let mut raw_buffer = Vec::new(); + for (r, g, b) in buffer { + let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8; + rgb565 |= (*g as u16 & 0b11111100) << 3; + rgb565 |= (*b as u16) >> 3; + raw_buffer.extend(rgb565.to_le_bytes()); + } + + std::fs::write(FB_PATH, &raw_buffer).unwrap(); + } +} + +pub fn update_ui( + task_tracker: &TaskTracker, + config: &config::Config, + ui_shutdown_rx: oneshot::Receiver<()>, + ui_update_rx: Receiver, +) { + generic_framebuffer::update_ui( + task_tracker, + config, + Framebuffer, + ui_shutdown_rx, + ui_update_rx, + ) +} diff --git a/installer/Cargo.toml b/installer/Cargo.toml index cfe747b..8b6ee3e 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -4,8 +4,11 @@ version = "0.3.4" edition = "2024" [dependencies] +aes = "0.8.4" anyhow = "1.0.98" axum = "0.8.3" +base64_light = "0.1.5" +block-padding = "0.3.3" bytes = "1.10.1" clap = { version = "4.5.37", features = ["derive"] } env_logger = "0.11.8" diff --git a/installer/build.rs b/installer/build.rs index 55b271b..0da4a53 100644 --- a/installer/build.rs +++ b/installer/build.rs @@ -8,17 +8,22 @@ fn main() { env!("CARGO_MANIFEST_DIR"), "/../target/armv7-unknown-linux-musleabihf/firmware/" )); - set_binary_var(&include_dir, "FILE_ROOTSHELL", "rootshell"); + set_binary_var(include_dir, "FILE_ROOTSHELL", "rootshell"); set_binary_var( - &include_dir, + include_dir, "FILE_RAYHUNTER_DAEMON_ORBIC", "rayhunter-daemon", ); set_binary_var( - &include_dir, + include_dir, "FILE_RAYHUNTER_DAEMON_TPLINK", "rayhunter-daemon", ); + set_binary_var( + include_dir, + "FILE_RAYHUNTER_DAEMON_WINGTECH", + "rayhunter-daemon", + ); } fn set_binary_var(include_dir: &Path, var: &str, file: &str) { @@ -26,7 +31,7 @@ fn set_binary_var(include_dir: &Path, var: &str, file: &str) { let out_dir = std::env::var("OUT_DIR").unwrap(); std::fs::create_dir_all(&out_dir).unwrap(); let blank = Path::new(&out_dir).join("blank"); - std::fs::write(&blank, &[]).unwrap(); + std::fs::write(&blank, []).unwrap(); println!("cargo::rustc-env={var}={}", blank.display()); return; } diff --git a/installer/src/main.rs b/installer/src/main.rs index 9de31e1..de785b8 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -4,6 +4,8 @@ use env_logger::Env; mod orbic; mod tplink; +mod util; +mod wingtech; pub static CONFIG_TOML: &str = include_str!("../../dist/config.toml.example"); pub static RAYHUNTER_DAEMON_INIT: &str = include_str!("../../dist/scripts/rayhunter_daemon"); @@ -21,6 +23,8 @@ enum Command { Orbic(InstallOrbic), /// Install rayhunter on the TP-Link M7350. Tplink(InstallTpLink), + /// Install rayhunter on the Wingtech CT2MHS01. + Wingtech(WingtechArgs), /// Developer utilities. Util(Util), } @@ -65,6 +69,10 @@ enum UtilSubCommand { Shell(Shell), /// Root the tplink and launch telnetd. TplinkStartTelnet(TplinkStartTelnet), + /// Root the Wingtech and launch telnetd. + WingtechStartTelnet(WingtechArgs), + /// Root the Wingtech and launch adb. + WingtechStartAdb(WingtechArgs), } #[derive(Parser, Debug)] @@ -74,6 +82,17 @@ struct TplinkStartTelnet { admin_ip: String, } +#[derive(Parser, Debug)] +struct WingtechArgs { + /// IP address for Wingtech admin interface, if custom. + #[arg(long, default_value = "192.168.1.1")] + admin_ip: String, + + /// Web portal admin password. + #[arg(long)] + admin_password: String, +} + #[derive(Parser, Debug)] struct Serial { #[arg(long)] @@ -91,6 +110,7 @@ async fn run() -> Result<(), Error> { match command { Command::Tplink(tplink) => tplink::main_tplink(tplink).await.context("Failed to install rayhunter on the TP-Link M7350. Make sure your computer is connected to the hotspot using USB tethering or WiFi.")?, Command::Orbic(_) => orbic::install().await.context("\nFailed to install rayhunter on the Orbic RC400L")?, + Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?, Command::Util(subcommand) => match subcommand.command { UtilSubCommand::Serial(serial_cmd) => { if serial_cmd.root { @@ -114,6 +134,8 @@ async fn run() -> Result<(), Error> { UtilSubCommand::TplinkStartTelnet(options) => { tplink::start_telnet(&options.admin_ip).await?; } + UtilSubCommand::WingtechStartTelnet(args) => wingtech::start_telnet(&args.admin_ip, &args.admin_password).await.context("\nFailed to start telnet on the Wingtech CT2MHS01")?, + UtilSubCommand::WingtechStartAdb(args) => wingtech::start_adb(&args.admin_ip, &args.admin_password).await.context("\nFailed to start adb on the Wingtech CT2MHS01")?, } } diff --git a/installer/src/orbic.rs b/installer/src/orbic.rs index 466fcb9..a09e7af 100644 --- a/installer/src/orbic.rs +++ b/installer/src/orbic.rs @@ -9,6 +9,7 @@ use nusb::{Device, Interface}; use sha2::{Digest, Sha256}; use tokio::time::sleep; +use crate::util::echo; use crate::{CONFIG_TOML, RAYHUNTER_DAEMON_INIT}; pub const ORBIC_NOT_FOUND: &str = r#"No Orbic device found. @@ -40,13 +41,6 @@ const RNDIS_INTERFACE: u8 = 0; #[cfg(not(target_os = "windows"))] const RNDIS_INTERFACE: u8 = 1; -macro_rules! echo { - ($($arg:tt)*) => { - print!($($arg)*); - let _ = std::io::stdout().flush(); - }; -} - pub async fn install() -> Result<()> { let mut adb_device = force_debug_mode().await?; echo!("Installing rootshell... "); diff --git a/installer/src/tplink.rs b/installer/src/tplink.rs index 5c4cc9e..50962a8 100644 --- a/installer/src/tplink.rs +++ b/installer/src/tplink.rs @@ -15,11 +15,10 @@ use bytes::{Bytes, BytesMut}; use hyper::StatusCode; use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor}; use serde::Deserialize; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; -use tokio::time::{sleep, timeout}; +use tokio::time::sleep; use crate::InstallTpLink; +use crate::util::{telnet_send_command, telnet_send_file}; type HttpProxyClient = hyper_util::client::legacy::Client; @@ -164,6 +163,7 @@ async fn tplink_run_install( rayhunter_daemon_bin, ) .await?; + telnet_send_file( addr, "/etc/init.d/rayhunter_daemon", @@ -200,99 +200,6 @@ async fn tplink_run_install( Ok(()) } -async fn telnet_send_file(addr: SocketAddr, filename: &str, payload: &[u8]) -> Result<(), Error> { - println!("Sending file {filename}"); - - // remove the old file just in case we are close to disk capacity. - telnet_send_command(addr, &format!("rm {filename}"), "").await?; - - { - let filename = filename.to_owned(); - let handle = tokio::spawn(async move { - telnet_send_command(addr, &format!("nc -l 0.0.0.0:8081 > {filename}.tmp"), "").await - }); - - sleep(Duration::from_millis(100)).await; - - let mut addr = addr; - addr.set_port(8081); - let mut stream = TcpStream::connect(addr).await?; - stream.write_all(payload).await?; - - handle.await??; - } - - let checksum = md5::compute(payload); - - telnet_send_command( - addr, - &format!("md5sum {filename}.tmp"), - &format!("{checksum:x} {filename}.tmp"), - ) - .await?; - - telnet_send_command( - addr, - &format!("mv {filename}.tmp {filename}"), - "exit code 0", - ) - .await?; - - Ok(()) -} - -async fn telnet_send_command( - addr: SocketAddr, - command: &str, - expected_output: &str, -) -> Result<(), Error> { - let stream = TcpStream::connect(addr).await?; - let (mut reader, mut writer) = stream.into_split(); - - loop { - let mut next_byte = 0; - reader - .read_exact(std::slice::from_mut(&mut next_byte)) - .await?; - if next_byte == b'#' { - break; - } - } - - writer.write_all(command.as_bytes()).await?; - writer.write_all(b"; echo exit code $?\r\n").await?; - - let mut read_buf = Vec::new(); - - let _ = timeout(Duration::from_secs(5), async { - let mut buf = [0; 4096]; - loop { - let Ok(bytes_read) = reader.read(&mut buf).await else { - break; - }; - let bytes = &buf[..bytes_read]; - if bytes.is_empty() { - continue; - } - - read_buf.extend(bytes); - - if read_buf.ends_with(b"/ # ") { - break; - } - } - }) - .await; - - let string = String::from_utf8_lossy(&read_buf); - - if !string.contains(expected_output) { - anyhow::bail!("{expected_output:?} not found in: {string}"); - } - - Ok(()) -} - #[derive(Clone)] struct AppState { client: HttpProxyClient, diff --git a/installer/src/util.rs b/installer/src/util.rs new file mode 100644 index 0000000..1f7ce86 --- /dev/null +++ b/installer/src/util.rs @@ -0,0 +1,90 @@ +use std::io::Write; +use std::net::SocketAddr; +use std::time::Duration; + +use anyhow::{Result, bail}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::time::{sleep, timeout}; + +macro_rules! echo { + ($($arg:tt)*) => { + print!($($arg)*); + let _ = std::io::stdout().flush(); + }; +} +pub(crate) use echo; + +pub async fn telnet_send_command( + addr: SocketAddr, + command: &str, + expected_output: &str, +) -> Result<()> { + let stream = TcpStream::connect(addr).await?; + let (mut reader, mut writer) = stream.into_split(); + loop { + let mut next_byte = 0; + reader + .read_exact(std::slice::from_mut(&mut next_byte)) + .await?; + if next_byte == b'#' { + break; + } + } + writer.write_all(command.as_bytes()).await?; + writer.write_all(b"; echo exit code $?\r\n").await?; + let mut read_buf = Vec::new(); + let _ = timeout(Duration::from_secs(5), async { + let mut buf = [0; 4096]; + loop { + let Ok(bytes_read) = reader.read(&mut buf).await else { + break; + }; + let bytes = &buf[..bytes_read]; + if bytes.is_empty() { + continue; + } + read_buf.extend(bytes); + if read_buf.ends_with(b"/ # ") { + break; + } + } + }) + .await; + let string = String::from_utf8_lossy(&read_buf); + if !string.contains(expected_output) { + bail!("{expected_output:?} not found in: {string}"); + } + Ok(()) +} + +pub async fn telnet_send_file(addr: SocketAddr, filename: &str, payload: &[u8]) -> Result<()> { + echo!("Sending file {filename} ... "); + { + let filename = filename.to_owned(); + let handle = tokio::spawn(async move { + telnet_send_command(addr, &format!("nc -l -p 8081 >{filename}.tmp"), "").await + }); + sleep(Duration::from_millis(100)).await; + let mut addr = addr; + addr.set_port(8081); + let mut stream = TcpStream::connect(addr).await?; + stream.write_all(payload).await?; + handle.await??; + } + let checksum = md5::compute(payload); + telnet_send_command( + addr, + &format!("md5sum {filename}.tmp"), + &format!("{checksum:x} {filename}.tmp"), + ) + .await?; + telnet_send_command( + addr, + &format!("mv {filename}.tmp {filename}"), + "exit code 0", + ) + .await?; + println!("ok"); + Ok(()) +} diff --git a/installer/src/wingtech.rs b/installer/src/wingtech.rs new file mode 100644 index 0000000..3ec7bc7 --- /dev/null +++ b/installer/src/wingtech.rs @@ -0,0 +1,182 @@ +/// Installer for the Wingtech CT2MHS01 hotspot. +/// +/// Tested on (from `/etc/wt_version`): +/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP +/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55 +/// WT_HARDWARE_VERSION=89323_1_20 +use std::io::Write; +use std::net::SocketAddr; +use std::str::FromStr; +use std::time::Duration; + +use aes::Aes128; +use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; +use anyhow::{Context, Result, bail}; +use base64_light::base64_encode_bytes; +use block_padding::{Padding, Pkcs7}; +use reqwest::Client; +use serde::Deserialize; +use tokio::time::sleep; + +use crate::WingtechArgs as Args; +use crate::util::{echo, telnet_send_command, telnet_send_file}; + +#[derive(Deserialize)] +struct LoginResponse { + token: String, +} + +pub async fn install( + Args { + admin_ip, + admin_password, + }: Args, +) -> Result<()> { + wingtech_run_install(admin_ip, admin_password).await +} + +const KEY: &[u8] = b"abcdefghijklmn12"; + +/// Returns password encrypted in AES128 ECB mode with the key b"abcdefghijklmn12", +/// with Pkcs7 padding, encoded in base64. +fn encrypt_password(password: &[u8]) -> Result { + let c = Aes128::new_from_slice(KEY)?; + let mut b = GenericArray::from([0u8; 16]); + b[..password.len()].copy_from_slice(password); + Pkcs7::pad(&mut b, password.len()); + c.encrypt_block(&mut b); + Ok(base64_encode_bytes(&b)) +} + +pub async fn start_telnet(admin_ip: &str, admin_password: &str) -> Result<()> { + run_command(admin_ip, admin_password, "busybox telnetd -l /bin/sh").await +} + +pub async fn start_adb(admin_ip: &str, admin_password: &str) -> Result<()> { + run_command(admin_ip, admin_password, "/sbin/usb/compositions/9025").await +} + +async fn run_command(admin_ip: &str, admin_password: &str, cmd: &str) -> Result<()> { + let qcmap_auth_endpoint = format!("http://{admin_ip}/cgi-bin/qcmap_auth"); + let qcmap_web_cgi_endpoint = format!("http://{admin_ip}/cgi-bin/qcmap_web_cgi"); + + let encrypted_pw = encrypt_password(admin_password.as_bytes()).ok().unwrap(); + + let client = Client::new(); + let LoginResponse { token } = client + .post(&qcmap_auth_endpoint) + .body(format!( + "type=login&pwd={encrypted_pw}&timeout=60000&user=admin" + )) + .send() + .await? + .json() + .await + .context("login did not return a token in response")?; + + let command = client.post(&qcmap_web_cgi_endpoint) + .body(format!("page=setFWMacFilter&cmd=add&mode=0&mac=50:5A:CA:B5:05||{cmd}&key=50:5A:CA:B5:05:AC&token={token}")) + .send() + .await?; + if command.status() != 200 { + bail!( + "running command failed with status code: {:?}", + command.status() + ); + } + + Ok(()) +} + +async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Result<()> { + echo!("Starting telnet ... "); + start_telnet(&admin_ip, &admin_password).await?; + println!("ok"); + + echo!("Connecting via telnet to {admin_ip} ... "); + let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap(); + telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0").await?; + println!("ok"); + + telnet_send_file( + addr, + "/data/rayhunter/config.toml", + crate::CONFIG_TOML.as_bytes(), + ) + .await?; + + let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON_WINGTECH")); + telnet_send_file( + addr, + "/data/rayhunter/rayhunter-daemon", + rayhunter_daemon_bin, + ) + .await?; + telnet_send_command( + addr, + "chmod 755 /data/rayhunter/rayhunter-daemon", + "exit code 0", + ) + .await?; + telnet_send_file( + addr, + "/etc/init.d/rayhunter_daemon", + crate::RAYHUNTER_DAEMON_INIT.as_bytes(), + ) + .await?; + telnet_send_command( + addr, + "chmod 755 /etc/init.d/rayhunter_daemon", + "exit code 0", + ) + .await?; + telnet_send_command(addr, "update-rc.d rayhunter_daemon defaults", "exit code 0").await?; + + println!("Rebooting device and waiting 30 seconds for it to start up."); + telnet_send_command(addr, "reboot", "exit code 0").await?; + sleep(Duration::from_secs(30)).await; + + echo!("Testing rayhunter ... "); + let max_failures = 10; + http_ok_every( + format!("http://{admin_ip}:8080/index.html"), + Duration::from_secs(3), + max_failures, + ) + .await?; + println!("ok"); + println!("rayhunter is running at http://{admin_ip}:8080"); + + Ok(()) +} + +async fn http_ok_every(rayhunter_url: String, interval: Duration, max_failures: u32) -> Result<()> { + let client = Client::new(); + let mut failures = 0; + loop { + match client.get(&rayhunter_url).send().await { + Ok(test) => match test.status().is_success() { + true => break, + false => bail!( + "request for url ({rayhunter_url}) failed with status code: {:?}", + test.status() + ), + }, + Err(e) => match failures > max_failures { + true => return Err(e.into()), + false => failures += 1, + }, + } + sleep(interval).await; + } + + Ok(()) +} + +#[test] +fn test_encrypt_password() { + let p = b"80536913"; + let s = encrypt_password(p).ok(); + let expected = Some("5brvd8xl732cSoFTAy67ig==".to_string()); + assert_eq!(s, expected); +} diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 6d18d96..24cbdc3 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -13,6 +13,7 @@ path = "src/lib.rs" default = [] orbic = [] tplink = [] +wingtech = [] [dependencies] bytes = "1.5.0"