diff --git a/Cargo.toml b/Cargo.toml index ac286c0..cef266e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/6grok-core", "crates/6grok-agent", "crates/6grok-api", + "crates/6grok-qcsuper", ] resolver = "2" diff --git a/README.md b/README.md index e3bcfc6..246fb3d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ The working implementation currently provides: - passive Qualcomm DIAG capture and raw replay; - active Qualcomm `DIAG_LOG_CONFIG_F` capability probing and log-mask configuration; - capability-aware `signaling`, `radio` and `full` Qualcomm profiles; +- GPL QCSuper interoperability backend for rooted Android `/dev/diag` through QCSuper `adb_bridge`; +- QCSuper-derived signaling and IP/DPL capture profiles with pinned provenance; - normalized JSONL capture/replay; - MediaTek 9-byte parser-record ingestion; - Samsung MIPC-style and raw-PDU ingestion using the parser's synthetic namespaces; @@ -26,19 +28,19 @@ The working implementation currently provides: ```text modem / phone | - | DIAG / SDM / vendor trace + | DIAG / QCSuper bridge / SDM / vendor trace v -+-------------+ +-------------+ +------------------+ -| 6grok-agent | ----> | 6grok-core | ----> | fivegrok-parser | -+-------------+ +-------------+ +------------------+ - | | | - | raw capture | MessagePack | decoded packets - v v v - replay +-----------+ JSON / history - | 6grok-api | - +-----------+ - / | \ - REST WS GSMTAP -> Wireshark ++----------------+ +-------------+ +------------------+ +| 6grok-agent / | --> | 6grok-core | ----> | fivegrok-parser | +| 6grok-qcsuper | +-------------+ +------------------+ ++----------------+ | | + | | MessagePack | decoded packets + v v v + raw / JSONL +-----------+ JSON / history + | 6grok-api | + +-----------+ + / | \ + REST WS GSMTAP -> Wireshark ``` ## Build @@ -55,9 +57,9 @@ rustup target add aarch64-unknown-linux-musl cargo build --release --target aarch64-unknown-linux-musl -p sixgrok-agent ``` -The produced executable is named `6grok-agent`. +The primary edge executable is named `6grok-agent`. The GPL QCSuper interoperability executable is `6grok-qcsuper`. -## Qualcomm capture +## Qualcomm serial/USB capture Passive capture from a modem that is already producing DIAG logs: @@ -97,6 +99,45 @@ Replay a raw Qualcomm capture: cargo run -p sixgrok-agent -- replay capture.bin ``` +## Rooted Android via QCSuper + +`6grok-qcsuper` interoperates with the TCP endpoint created by QCSuper's GPL `adb_bridge`. This is useful on Qualcomm Android devices where `/dev/diag` requires the diagchar setup logic already implemented and tested by QCSuper. + +QCSuper's default bridge port is TCP 43555. Once its bridge is running and forwarded by ADB, probe the modem from 6grok: + +```bash +cargo run -p sixgrok-qcsuper -- probe +``` + +Capture QCSuper's established signaling selection: + +```bash +cargo run -p sixgrok-qcsuper -- capture --profile signaling +``` + +Capture Qualcomm IP/DPL records: + +```bash +cargo run -p sixgrok-qcsuper -- capture --profile ip +``` + +Or request the union and send normalized frames directly to a remote API service: + +```bash +cargo run -p sixgrok-qcsuper -- capture \ + --profile full \ + --frame-capture android.jsonl \ + --server 10.0.0.2:5566 +``` + +For a non-default forwarded endpoint: + +```bash +cargo run -p sixgrok-qcsuper -- capture --bridge 127.0.0.1:43556 --profile signaling +``` + +The backend does not vendor QCSuper's Android executable. QCSuper remains the source of the on-device `/dev/diag` bridge; 6grok speaks its HDLC-over-TCP interface and performs DIAG log configuration itself. The integration is pinned to QCSuper commit `aa555b4f7f25f7a8bf4e5afd4dcb884edf2f6735` and its source-level provenance is recorded in [`THIRD_PARTY.md`](THIRD_PARTY.md). + ## Service / remote agents Start the aggregation service: @@ -149,10 +190,10 @@ See [`docs/MULTI_VENDOR.md`](docs/MULTI_VENDOR.md). 6grok intentionally uses a **multi-license architecture**. -- The combined `6grok-agent` application is `GPL-3.0-or-later`. +- The combined `6grok-agent` application and `sixgrok-qcsuper` backend are `GPL-3.0-or-later`. - Original reusable `sixgrok-core` and `sixgrok-api` code is available under `MIT OR GPL-3.0-or-later` where indicated by repository metadata. - Third-party files retain their exact upstream license, copyright and notices. -- QCSuper (`GPL-3.0-or-later`) and SCAT (`GPL-2.0-or-later`) source may be reused/adapted in the GPL application with explicit provenance. +- QCSuper (`GPL-3.0-or-later`) and SCAT (`GPL-2.0-or-later`) source may be reused/adapted in GPL application components with explicit provenance. - MIT, Apache-2.0 and compatible BSD material may also be incorporated while retaining its original terms. - `GPL-2.0-only`, AGPL and other licenses outside the reviewed compatibility policy are not imported into the combined application without explicit review. @@ -164,7 +205,7 @@ The root [`LICENSE`](LICENSE) contains the GPLv3 license text. See [`docs/LICENS - native MediaTek mdlogger/CCCI acquisition; - validated native Samsung Shannon SDM acquisition; -- additional Qualcomm Android/USB transports and QCSuper-derived capabilities; +- direct Android diagchar backend where it adds value beyond QCSuper bridge interoperability; - GPS/NMEA/gpsd synchronized location frames; - AT-monitor fallback for DIAG-locked devices; - persistent capture/database backend; diff --git a/REUSE.toml b/REUSE.toml index f03362c..887d5b8 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -31,6 +31,7 @@ path = [ "crates/6grok-agent/src/profiles.rs", "crates/6grok-agent/src/qualcomm.rs", "crates/6grok-agent/src/vendor_records.rs", + "crates/6grok-qcsuper/Cargo.toml", ] SPDX-FileCopyrightText = "2026 mbound" SPDX-License-Identifier = "GPL-3.0-or-later" diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index e5902fd..9cc5e9d 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -55,15 +55,40 @@ Its upstream-specific MIT copy remains preserved in `THIRD_PARTY_LICENSES/fivegr ## QCSuper -QCSuper declares GPL-3.0+ / GPL-3.0-or-later. Its code may now be copied or adapted into GPL-covered 6grok application components. When this is done, keep upstream copyright/license information and add a provenance comment identifying the upstream commit and source path. +QCSuper is currently pinned for provenance at: + +`aa555b4f7f25f7a8bf4e5afd4dcb884edf2f6735` (QCSuper 2.1.3, 2026-07-23) + +QCSuper declares GPL-3.0+ / GPL-3.0-or-later. Its code may be copied or adapted into GPL-covered 6grok application components. The first integration is `crates/6grok-qcsuper`, a Rust interoperability backend for QCSuper's Android `/dev/diag` TCP bridge. + +Upstream material used for that backend: + +| Upstream path | 6grok use | +|---|---| +| `src/qcsuper/inputs/adb.py` | bridge address/transport behavior and HDLC-over-TCP interoperability | +| `src/qcsuper/inputs/adb_bridge/adb_bridge.c` | bridge framing/stream behavior and Android `/dev/diag` implementation reference | +| `src/qcsuper/modules/_enable_log_mixin.py` | translated/adapted signaling and IP/DPL capture selections | +| `src/qcsuper/inputs/_hdlc_mixin.py` | DIAG HDLC interoperability reference | + +The bridge client itself is written in Rust for 6grok and carries GPL-3.0-or-later SPDX metadata. QCSuper-derived log selections explicitly record the upstream commit/path in source comments. The standard GPLv3 text is retained under `LICENSES/GPL-3.0-or-later.txt` and as the repository root `LICENSE`. Do not move QCSuper-derived code into a component advertised as MIT-only. If functionality needs to be shared with a permissive library, isolate an independently written interface/data model from the GPL-derived implementation. +### Qualcomm log-mask semantics cross-check + +QCSuper calls the range value a log-mask bit size in parts of its implementation, but Qualcomm DIAG sources and Osmocom model the protocol field as inclusive `last_item`. 6grok therefore deliberately retains an inclusive mask length of `floor(last_item / 8) + 1` bytes. This is covered by regression tests, including a boundary where `last_item == 8` and bit 8 must occupy a second byte. + +References used for this protocol cross-check include Qualcomm `diaglog.c` implementations and `osmocom/osmo-qcdiag/src/diag_log.c`; no Qualcomm source is copied into the dual-licensed core. + ## SCAT +SCAT is currently pinned for provenance at: + +`361ff551a4fbb30789c46750c00586682a7a9b26` (2026-09-03) + SCAT declares `GPL-2.0-or-later`. This is compatible with the GPLv3 6grok application because the "or later" grant permits selecting GPLv3 terms for the combined work. -SCAT-derived files must retain their `GPL-2.0-or-later` identity and copyright. Do not rewrite their file-level SPDX identifier to GPL-3.0 merely because the combined binary is distributed under GPLv3 terms. +SCAT is being used to corroborate Qualcomm DIAG behavior and as the primary implementation reference for native Shannon/MediaTek acquisition work. SCAT-derived files must retain their `GPL-2.0-or-later` identity and copyright. Do not rewrite their file-level SPDX identifier to GPL-3.0 merely because the complete executable is conveyed under compatible GPLv3 terms. ## Apache/BSD sources diff --git a/crates/6grok-qcsuper/Cargo.toml b/crates/6grok-qcsuper/Cargo.toml new file mode 100644 index 0000000..e690e74 --- /dev/null +++ b/crates/6grok-qcsuper/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "sixgrok-qcsuper" +version = "0.1.0" +edition.workspace = true +license = "GPL-3.0-or-later" +repository.workspace = true +rust-version.workspace = true + +[[bin]] +name = "6grok-qcsuper" +path = "src/main.rs" + +[dependencies] +sixgrok-core = { path = "../6grok-core" } +anyhow.workspace = true +clap.workspace = true +crc.workspace = true +serde_json.workspace = true diff --git a/crates/6grok-qcsuper/src/main.rs b/crates/6grok-qcsuper/src/main.rs new file mode 100644 index 0000000..7cc8d5d --- /dev/null +++ b/crates/6grok-qcsuper/src/main.rs @@ -0,0 +1,407 @@ +// SPDX-FileCopyrightText: 2026 mbound +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Interoperability backend for the TCP bridge shipped by P1sec/QCSuper. +// QCSuper provenance used by this module: +// repository: https://github.com/P1sec/QCSuper +// commit: aa555b4f7f25f7a8bf4e5afd4dcb884edf2f6735 +// bridge client: src/qcsuper/inputs/adb.py +// bridge server: src/qcsuper/inputs/adb_bridge/adb_bridge.c +// The wire-compatible implementation below is written in Rust for 6grok; +// QCSuper's bridge protocol and active log selections are GPL-3.0-or-later. + +mod profiles; +mod protocol; + +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; +use profiles::{merge_profiles, QcsuperProfile}; +use protocol::{ + disable_logging_request, encode_hdlc, group_log_codes, parse_id_ranges_response, + parse_log_config_header, retrieve_id_ranges_request, set_mask_request, split_log_code, + HdlcDecoder, LOG_CONFIG_DISABLE_OP, LOG_CONFIG_RETRIEVE_ID_RANGES_OP, + LOG_CONFIG_SET_MASK_OP, +}; +use sixgrok_core::{ + encode_wire_frame, parser_payload, qualcomm_log_code, CaptureFrame, Vendor, +}; +use std::collections::BTreeSet; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const DEFAULT_QCSUPER_BRIDGE: &str = "127.0.0.1:43555"; + +#[derive(Debug, Parser)] +#[command(name = "6grok-qcsuper")] +#[command(about = "GPL Qualcomm backend for QCSuper Android /dev/diag bridge interoperability")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Capture Qualcomm DIAG from a running QCSuper adb_bridge TCP endpoint. + Capture { + /// QCSuper bridge address. Its default TCP port is 43555. + #[arg(long, default_value = DEFAULT_QCSUPER_BRIDGE)] + bridge: String, + /// Explicit Qualcomm log code to enable, e.g. --log 0xb821. + #[arg(long = "log", value_parser = parse_u16_auto)] + logs: Vec, + /// QCSuper-derived capture profile. May be repeated. + #[arg(long = "profile", value_enum)] + profiles: Vec, + /// Save normalized CaptureFrame objects as JSON Lines. + #[arg(long)] + frame_capture: Option, + /// Stream normalized frames to a 6grok-api ingest listener. + #[arg(long)] + server: Option, + }, + /// Query the bridge-connected modem's DIAG log equipment-ID ranges. + Probe { + #[arg(long, default_value = DEFAULT_QCSUPER_BRIDGE)] + bridge: String, + }, + /// Disable DIAG logging on the bridge-connected modem. + Disable { + #[arg(long, default_value = DEFAULT_QCSUPER_BRIDGE)] + bridge: String, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Command::Capture { + bridge, + logs, + profiles, + frame_capture, + server, + } => { + let mut stream = connect_bridge(&bridge)?; + if !logs.is_empty() || !profiles.is_empty() { + configure_logs(&mut stream, &logs, &profiles)?; + } + run_capture( + stream, + open_optional(frame_capture.as_deref())?, + WireSink::connect_optional(server.as_deref())?, + ) + } + Command::Probe { bridge } => { + let mut stream = connect_bridge(&bridge)?; + let ranges = query_log_ranges(&mut stream)?; + println!("equipment_id,last_item,max_log_code"); + for (equip, last_item) in ranges.into_iter().enumerate() { + let max_code = ((equip as u32) << 12) | (last_item & 0x0fff); + println!("{equip},0x{last_item:03x},0x{max_code:04x}"); + } + Ok(()) + } + Command::Disable { bridge } => { + let mut stream = connect_bridge(&bridge)?; + send_diag_packet(&mut stream, &disable_logging_request())?; + let response = wait_for_log_config_response(&mut stream, LOG_CONFIG_DISABLE_OP)?; + let (header, _) = parse_log_config_header(&response)?; + if header.status != 0 { + bail!("modem rejected disable request with status {}", header.status); + } + eprintln!("6grok-qcsuper: modem DIAG logging disabled"); + Ok(()) + } + } +} + +fn connect_bridge(address: &str) -> Result { + let stream = TcpStream::connect(address) + .with_context(|| format!("connecting to QCSuper adb_bridge at {address}"))?; + stream + .set_nodelay(true) + .context("enabling TCP_NODELAY on QCSuper bridge")?; + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .context("setting QCSuper bridge read timeout")?; + eprintln!("6grok-qcsuper: connected to QCSuper bridge at {address}"); + Ok(stream) +} + +fn configure_logs( + stream: &mut TcpStream, + explicit: &[u16], + profiles: &[QcsuperProfile], +) -> Result<()> { + let ranges = query_log_ranges(stream)?; + let explicit_set: BTreeSet = explicit.iter().copied().collect(); + let profile_codes = merge_profiles(&[], profiles); + let mut selected = explicit.to_vec(); + + for code in profile_codes { + if explicit_set.contains(&code) { + continue; + } + let (equip, item) = split_log_code(code); + let last_item = ranges[usize::from(equip)]; + if u32::from(item) <= last_item { + selected.push(code); + } else { + eprintln!( + "6grok-qcsuper: profile log 0x{code:04x} unsupported by modem (equipment {equip} last_item=0x{last_item:03x}); skipping" + ); + } + } + selected.sort_unstable(); + selected.dedup(); + + for (equip, items) in group_log_codes(&selected) { + let last_item = ranges[usize::from(equip)]; + for &item in &items { + let code = (u16::from(equip) << 12) | item; + if explicit_set.contains(&code) && u32::from(item) > last_item { + bail!( + "explicit log 0x{code:04x} exceeds modem equipment {equip} last_item 0x{last_item:03x}" + ); + } + } + + let request = set_mask_request(equip, last_item, &items)?; + send_diag_packet(stream, &request)?; + let response = wait_for_log_config_response(stream, LOG_CONFIG_SET_MASK_OP)?; + let (header, _) = parse_log_config_header(&response)?; + if header.status != 0 { + bail!( + "modem rejected log mask for equipment ID {equip} with status {}", + header.status + ); + } + + let codes: Vec = items + .iter() + .map(|item| format!("0x{:04x}", (u16::from(equip) << 12) | item)) + .collect(); + eprintln!( + "6grok-qcsuper: enabled equipment {equip} through last_item 0x{last_item:03x}: {}", + codes.join(", ") + ); + } + + Ok(()) +} + +fn query_log_ranges(stream: &mut TcpStream) -> Result<[u32; 16]> { + send_diag_packet(stream, &retrieve_id_ranges_request())?; + let response = wait_for_log_config_response(stream, LOG_CONFIG_RETRIEVE_ID_RANGES_OP)?; + Ok(parse_id_ranges_response(&response)?) +} + +fn send_diag_packet(stream: &mut TcpStream, packet: &[u8]) -> Result<()> { + stream + .write_all(&encode_hdlc(packet)) + .context("writing DIAG request to QCSuper bridge")?; + stream.flush().context("flushing QCSuper bridge request")?; + Ok(()) +} + +fn wait_for_log_config_response( + stream: &mut TcpStream, + expected_operation: u32, +) -> Result> { + let mut hdlc = HdlcDecoder::default(); + let mut buf = [0_u8; 8192]; + let mut idle_timeouts = 0_u8; + + loop { + let n = match stream.read(&mut buf) { + Ok(0) => bail!("QCSuper bridge closed while waiting for DIAG response"), + Ok(n) => { + idle_timeouts = 0; + n + } + Err(err) + if err.kind() == io::ErrorKind::WouldBlock + || err.kind() == io::ErrorKind::TimedOut => + { + idle_timeouts += 1; + if idle_timeouts >= 5 { + bail!( + "timed out waiting for DIAG log-config operation {expected_operation}" + ); + } + continue; + } + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => return Err(err).context("reading QCSuper bridge DIAG response"), + }; + + for &byte in &buf[..n] { + let Some(result) = hdlc.push(byte) else { + continue; + }; + let packet = match result { + Ok(packet) => packet, + Err(err) => { + eprintln!("6grok-qcsuper: ignoring invalid DIAG frame: {err}"); + continue; + } + }; + if let Ok((header, _)) = parse_log_config_header(&packet) { + if header.operation == expected_operation { + return Ok(packet); + } + } + } + } +} + +fn run_capture( + mut stream: TcpStream, + mut frame_capture: Option, + mut wire_sink: Option, +) -> Result<()> { + // The bridge is a streaming endpoint once setup is complete. Remove the + // request/response timeout so an idle radio does not cause a busy loop. + stream + .set_read_timeout(None) + .context("clearing QCSuper bridge read timeout")?; + + let start = Instant::now(); + let mut sequence = 0_u64; + let mut hdlc = HdlcDecoder::default(); + let mut buf = [0_u8; 64 * 1024]; + + loop { + let n = match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => n, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => return Err(err).context("reading QCSuper bridge stream"), + }; + + for &byte in &buf[..n] { + let Some(result) = hdlc.push(byte) else { + continue; + }; + let packet = match result { + Ok(packet) => packet, + Err(err) => { + eprintln!("6grok-qcsuper: dropping invalid DIAG frame: {err}"); + continue; + } + }; + let Some(log_code) = qualcomm_log_code(&packet) else { + continue; + }; + + sequence += 1; + let frame = CaptureFrame { + sequence, + timestamp_wall: unix_ms(), + timestamp_mono: start.elapsed().as_millis() as u64, + vendor: Vendor::Qualcomm, + log_code, + payload: parser_payload(log_code, &packet), + }; + emit_frame(&frame, &mut frame_capture, &mut wire_sink)?; + } + } + + flush_optional(&mut frame_capture)?; + Ok(()) +} + +fn emit_frame( + frame: &CaptureFrame, + frame_capture: &mut Option, + wire_sink: &mut Option, +) -> Result<()> { + if let Some(file) = frame_capture.as_mut() { + serde_json::to_writer(&mut *file, frame)?; + file.write_all(b"\n")?; + } + if let Some(sink) = wire_sink.as_mut() { + sink.send(frame)?; + } + println!("{}", serde_json::to_string(&frame.decode())?); + Ok(()) +} + +struct WireSink { + stream: TcpStream, +} + +impl WireSink { + fn connect_optional(address: Option<&str>) -> Result> { + address + .map(|address| { + let stream = TcpStream::connect(address) + .with_context(|| format!("connecting to 6grok-api ingest at {address}"))?; + stream + .set_nodelay(true) + .context("enabling TCP_NODELAY for 6grok-api uplink")?; + eprintln!("6grok-qcsuper: streaming frames to {address}"); + Ok(Self { stream }) + }) + .transpose() + } + + fn send(&mut self, frame: &CaptureFrame) -> Result<()> { + let payload = encode_wire_frame(frame).context("encoding MessagePack agent frame")?; + let len = u32::try_from(payload.len()).context("agent frame exceeds u32 wire length")?; + self.stream + .write_all(&len.to_be_bytes()) + .context("writing agent frame length")?; + self.stream + .write_all(&payload) + .context("writing agent MessagePack frame")?; + Ok(()) + } +} + +fn open_optional(path: Option<&Path>) -> Result> { + path.map(|path| { + File::create(path).with_context(|| format!("creating capture {}", path.display())) + }) + .transpose() +} + +fn flush_optional(file: &mut Option) -> Result<()> { + if let Some(file) = file.as_mut() { + file.flush()?; + } + Ok(()) +} + +fn parse_u16_auto(value: &str) -> std::result::Result { + let value = value.trim(); + if let Some(hex) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + u16::from_str_radix(hex, 16).map_err(|err| err.to_string()) + } else { + value.parse::().map_err(|err| err.to_string()) + } +} + +fn unix_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_hex_and_decimal_log_codes() { + assert_eq!(parse_u16_auto("0xb821").unwrap(), 0xb821); + assert_eq!(parse_u16_auto("47137").unwrap(), 47137); + } +} diff --git a/crates/6grok-qcsuper/src/profiles.rs b/crates/6grok-qcsuper/src/profiles.rs new file mode 100644 index 0000000..c613b07 --- /dev/null +++ b/crates/6grok-qcsuper/src/profiles.rs @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2026 mbound +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Log-code selections adapted from P1sec/QCSuper: +// repository: https://github.com/P1sec/QCSuper +// upstream commit: aa555b4f7f25f7a8bf4e5afd4dcb884edf2f6735 +// upstream path: src/qcsuper/modules/_enable_log_mixin.py +// QCSuper declares GPL-3.0+ / GPL-3.0-or-later. The pinned upstream file has +// no per-file copyright header; repository/project attribution is preserved +// here and in THIRD_PARTY.md. +// Modified/translated for 6grok on 2026-09-05. + +use clap::ValueEnum; + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum QcsuperProfile { + /// 2G/3G/4G/5G Layer-2/Layer-3/NAS logs used by QCSuper raw packet capture. + Signaling, + /// Qualcomm Data Protocol Logging (DPL) IP traffic records. + Ip, + /// Union of signaling and IP records. + Full, +} + +/// QCSuper `TYPES_FOR_RAW_PACKET_LOGGING` at the pinned revision. +const SIGNALING: &[u16] = &[ + 0x5226, // GPRS MAC signalling + 0x512f, // GSM RR signalling + 0x412f, // WCDMA signalling + 0xb0c0, // LTE RRC OTA + 0xb821, // NR RRC OTA + 0x713a, // UMTS NAS OTA + 0xb0e2, // LTE NAS ESM incoming + 0xb0e3, // LTE NAS ESM outgoing + 0xb0ec, // LTE NAS EMM incoming + 0xb0ed, // LTE NAS EMM outgoing +]; + +/// QCSuper `TYPES_FOR_IP_TRAFFIC_LOGGING` at the pinned revision. +const IP: &[u16] = &[ + 0x11eb, // Data Protocol Logging + 0x1574, // Network IP RM TX full + 0x1575, // Network IP RM RX full +]; + +impl QcsuperProfile { + pub fn codes(self) -> Vec { + match self { + Self::Signaling => SIGNALING.to_vec(), + Self::Ip => IP.to_vec(), + Self::Full => { + let mut values = SIGNALING.to_vec(); + values.extend_from_slice(IP); + values.sort_unstable(); + values.dedup(); + values + } + } + } +} + +pub fn merge_profiles(explicit: &[u16], profiles: &[QcsuperProfile]) -> Vec { + let mut values = explicit.to_vec(); + for profile in profiles { + values.extend(profile.codes()); + } + values.sort_unstable(); + values.dedup(); + values +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signaling_contains_qcsuper_lte_and_nr_rrc() { + let profile = QcsuperProfile::Signaling.codes(); + assert!(profile.contains(&0xb0c0)); + assert!(profile.contains(&0xb821)); + assert!(profile.contains(&0x412f)); + } + + #[test] + fn ip_profile_contains_dpl_trigger_codes() { + assert_eq!(QcsuperProfile::Ip.codes(), vec![0x11eb, 0x1574, 0x1575]); + } +} diff --git a/crates/6grok-qcsuper/src/protocol.rs b/crates/6grok-qcsuper/src/protocol.rs new file mode 100644 index 0000000..46b39ce --- /dev/null +++ b/crates/6grok-qcsuper/src/protocol.rs @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: 2026 mbound +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Qualcomm DIAG framing/control primitives shared conceptually with the +// original 6grok agent implementation. The log-config packet layout is also +// independently corroborated by P1sec/QCSuper at commit +// aa555b4f7f25f7a8bf4e5afd4dcb884edf2f6735. + +use crc::{Crc, CRC_16_IBM_SDLC}; +use std::collections::BTreeMap; +use std::fmt; + +const DIAG_CRC: Crc = Crc::::new(&CRC_16_IBM_SDLC); +pub const DIAG_LOG_CONFIG_F: u8 = 0x73; +pub const LOG_CONFIG_DISABLE_OP: u32 = 0; +pub const LOG_CONFIG_RETRIEVE_ID_RANGES_OP: u32 = 1; +pub const LOG_CONFIG_SET_MASK_OP: u32 = 3; + +#[derive(Debug, Default)] +pub struct HdlcDecoder { + frame: Vec, + escaped: bool, +} + +impl HdlcDecoder { + pub fn push(&mut self, byte: u8) -> Option, FrameError>> { + match byte { + 0x7e => { + self.escaped = false; + if self.frame.is_empty() { + return None; + } + let raw = std::mem::take(&mut self.frame); + Some(validate_diag_frame(raw)) + } + 0x7d => { + self.escaped = true; + None + } + value => { + self.frame.push(if self.escaped { value ^ 0x20 } else { value }); + self.escaped = false; + None + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameError { + TooShort, + BadCrc { expected: u16, actual: u16 }, +} + +impl fmt::Display for FrameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TooShort => write!(f, "frame shorter than DIAG payload + CRC"), + Self::BadCrc { expected, actual } => write!( + f, + "CRC mismatch (wire=0x{expected:04x}, calculated=0x{actual:04x})" + ), + } + } +} + +pub fn validate_diag_frame(mut raw: Vec) -> Result, FrameError> { + if raw.len() < 3 { + return Err(FrameError::TooShort); + } + let crc_pos = raw.len() - 2; + let expected = u16::from_le_bytes([raw[crc_pos], raw[crc_pos + 1]]); + let actual = DIAG_CRC.checksum(&raw[..crc_pos]); + if expected != actual { + return Err(FrameError::BadCrc { expected, actual }); + } + raw.truncate(crc_pos); + Ok(raw) +} + +pub fn encode_hdlc(packet: &[u8]) -> Vec { + let crc = DIAG_CRC.checksum(packet).to_le_bytes(); + let mut out = Vec::with_capacity(packet.len() + 8); + for byte in packet.iter().copied().chain(crc) { + match byte { + 0x7d | 0x7e => { + out.push(0x7d); + out.push(byte ^ 0x20); + } + other => out.push(other), + } + } + out.push(0x7e); + out +} + +pub fn log_config_request(operation: u32) -> Vec { + let mut packet = Vec::with_capacity(8); + packet.push(DIAG_LOG_CONFIG_F); + packet.extend_from_slice(&[0, 0, 0]); + packet.extend_from_slice(&operation.to_le_bytes()); + packet +} + +pub fn retrieve_id_ranges_request() -> Vec { + log_config_request(LOG_CONFIG_RETRIEVE_ID_RANGES_OP) +} + +pub fn disable_logging_request() -> Vec { + log_config_request(LOG_CONFIG_DISABLE_OP) +} + +pub const fn split_log_code(code: u16) -> (u8, u16) { + (((code >> 12) & 0x0f) as u8, code & 0x0fff) +} + +pub fn group_log_codes(codes: &[u16]) -> BTreeMap> { + let mut grouped: BTreeMap> = BTreeMap::new(); + for &code in codes { + let (equip, item) = split_log_code(code); + grouped.entry(equip).or_default().push(item); + } + for items in grouped.values_mut() { + items.sort_unstable(); + items.dedup(); + } + grouped +} + +pub fn set_mask_request( + equip_id: u8, + last_item: u32, + items: &[u16], +) -> Result, ControlError> { + if equip_id > 0x0f { + return Err(ControlError::InvalidEquipmentId(equip_id)); + } + let mask_len = (last_item as usize / 8) + 1; + let mut mask = vec![0_u8; mask_len]; + for &item in items { + if u32::from(item) > last_item { + return Err(ControlError::ItemOutOfRange { + equip_id, + item, + last_item, + }); + } + mask[usize::from(item) / 8] |= 1 << (item % 8); + } + + let mut packet = Vec::with_capacity(16 + mask.len()); + packet.push(DIAG_LOG_CONFIG_F); + packet.extend_from_slice(&[0, 0, 0]); + packet.extend_from_slice(&LOG_CONFIG_SET_MASK_OP.to_le_bytes()); + packet.extend_from_slice(&u32::from(equip_id).to_le_bytes()); + packet.extend_from_slice(&last_item.to_le_bytes()); + packet.extend_from_slice(&mask); + Ok(packet) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LogConfigHeader { + pub operation: u32, + pub status: u32, +} + +pub fn parse_log_config_header( + packet: &[u8], +) -> Result<(LogConfigHeader, &[u8]), ControlError> { + if packet.len() < 12 || packet[0] != DIAG_LOG_CONFIG_F { + return Err(ControlError::NotLogConfigResponse); + } + let operation = read_u32(packet, 4)?; + let status = read_u32(packet, 8)?; + Ok((LogConfigHeader { operation, status }, &packet[12..])) +} + +pub fn parse_id_ranges_response(packet: &[u8]) -> Result<[u32; 16], ControlError> { + let (header, data) = parse_log_config_header(packet)?; + if header.operation != LOG_CONFIG_RETRIEVE_ID_RANGES_OP { + return Err(ControlError::UnexpectedOperation(header.operation)); + } + if header.status != 0 { + return Err(ControlError::RemoteStatus(header.status)); + } + if data.len() < 16 * 4 { + return Err(ControlError::TruncatedResponse); + } + let mut ranges = [0_u32; 16]; + for (idx, slot) in ranges.iter_mut().enumerate() { + *slot = read_u32(data, idx * 4)?; + } + Ok(ranges) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 4] = data + .get(offset..offset + 4) + .ok_or(ControlError::TruncatedResponse)? + .try_into() + .map_err(|_| ControlError::TruncatedResponse)?; + Ok(u32::from_le_bytes(bytes)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ControlError { + InvalidEquipmentId(u8), + ItemOutOfRange { equip_id: u8, item: u16, last_item: u32 }, + NotLogConfigResponse, + UnexpectedOperation(u32), + RemoteStatus(u32), + TruncatedResponse, +} + +impl fmt::Display for ControlError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidEquipmentId(id) => write!(f, "invalid equipment ID {id}"), + Self::ItemOutOfRange { equip_id, item, last_item } => write!( + f, + "log item 0x{item:03x} exceeds equipment {equip_id} last_item 0x{last_item:03x}" + ), + Self::NotLogConfigResponse => write!(f, "packet is not a DIAG_LOG_CONFIG_F response"), + Self::UnexpectedOperation(op) => write!(f, "unexpected log-config operation {op}"), + Self::RemoteStatus(status) => write!(f, "modem returned log-config status {status}"), + Self::TruncatedResponse => write!(f, "truncated log-config response"), + } + } +} + +impl std::error::Error for ControlError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hdlc_roundtrip_handles_escaping() { + let packet = [0x73, 0x7d, 0x7e, 0x00, 0x55]; + let encoded = encode_hdlc(&packet); + let mut decoder = HdlcDecoder::default(); + let mut decoded = None; + for byte in encoded { + if let Some(result) = decoder.push(byte) { + decoded = Some(result.unwrap()); + } + } + assert_eq!(decoded.unwrap(), packet); + } + + #[test] + fn builds_qcsuper_compatible_log_config_header() { + assert_eq!(retrieve_id_ranges_request(), vec![0x73, 0, 0, 0, 1, 0, 0, 0]); + let packet = set_mask_request(0x0b, 8, &[0, 8]).unwrap(); + assert_eq!(&packet[..16], &[0x73, 0, 0, 0, 3, 0, 0, 0, 0x0b, 0, 0, 0, 8, 0, 0, 0]); + assert_eq!(&packet[16..], &[0x01, 0x01]); + } +}