use std::collections::HashSet;
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::time::{Duration, Instant};
use bytes::{BufMut, BytesMut};
use rand::RngExt;
use tokio::sync::broadcast::error::TryRecvError;
use peashape::{CoverGenerator, Lane, Node, ShapeConfig, ShapingScope, ShapingStrategy, Target};
const TS_SYNC: u8 = 0x47;
const TS_PACKET: usize = 188;
const TS_HEADER: usize = 4;
const TS_PAYLOAD: usize = TS_PACKET - TS_HEADER;
const VIDEO_PID: u16 = 0x0100;
const TS_INTERVAL: Duration = Duration::from_millis(10);
const KEY: u32 = 0x5a5a_5a5a;
const NONCE: usize = 4;
const REAL_MAGIC: &[u8; 4] = b"TS!!";
fn keystream(nonce: u32, i: usize) -> u8 {
let n = nonce.wrapping_add(i as u32).wrapping_mul(2_654_435_761);
(KEY ^ n ^ (n >> 13)) as u8
}
fn build_ts_packet(message: Option<&[u8]>) -> BytesMut {
let mut f = BytesMut::with_capacity(TS_PACKET);
let mut rng = rand::rng();
f.put_bytes(0, TS_HEADER);
let mut payload = vec![0u8; TS_PAYLOAD];
rng.fill(&mut payload[..]);
if let Some(msg) = message {
let nonce = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
let take = msg.len().min(TS_PAYLOAD - NONCE - REAL_MAGIC.len() - 1);
let mut pt = Vec::with_capacity(REAL_MAGIC.len() + 1 + take);
pt.extend_from_slice(REAL_MAGIC);
pt.push(take as u8);
pt.extend_from_slice(&msg[..take]);
for (i, b) in pt.iter().enumerate() {
payload[NONCE + i] = b ^ keystream(nonce, i);
}
}
f.extend_from_slice(&payload);
debug_assert_eq!(f.len(), TS_PACKET);
f
}
fn parse_ts(f: &[u8]) -> Option<(u16, u8, bool, u8)> {
if f.len() < TS_HEADER || f[0] != TS_SYNC {
return None;
}
let pusi = f[1] & 0x40 != 0;
let pid = (((f[1] & 0x1f) as u16) << 8) | f[2] as u16;
let scrambling = f[3] >> 6;
let cc = f[3] & 0x0f;
Some((pid, cc, pusi, scrambling))
}
fn recover(frame: &[u8]) -> Option<Vec<u8>> {
let payload = frame.get(TS_HEADER..TS_PACKET)?;
let nonce = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
let body = &payload[NONCE..];
let mut pt = vec![0u8; body.len()];
for (i, b) in body.iter().enumerate() {
pt[i] = b ^ keystream(nonce, i);
}
if &pt[..REAL_MAGIC.len()] != REAL_MAGIC {
return None;
}
let len = pt[REAL_MAGIC.len()] as usize;
let start = REAL_MAGIC.len() + 1;
pt.get(start..start + len).map(<[u8]>::to_vec)
}
struct TsMuxer {
pid: u16,
cc: AtomicU8,
}
impl TsMuxer {
fn new(pid: u16) -> Self {
Self {
pid,
cc: AtomicU8::new(0),
}
}
fn stamp(&self, f: &mut [u8], pusi: bool) {
let cc = self.cc.fetch_add(1, Ordering::Relaxed) & 0x0f;
f[0] = TS_SYNC;
f[1] = (u8::from(pusi) << 6) | ((self.pid >> 8) as u8 & 0x1f);
f[2] = (self.pid & 0xff) as u8;
f[3] = 0xd0 | cc;
}
}
impl CoverGenerator for TsMuxer {
fn cover(&self, _config: &ShapeConfig) -> BytesMut {
let mut f = build_ts_packet(None);
self.stamp(&mut f, false);
f
}
fn finalize_real(&self, _config: &ShapeConfig, mut frame: BytesMut) -> BytesMut {
self.stamp(&mut frame, true);
frame
}
}
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
const VIEWERS: usize = 3;
println!("peashape — masquerading broadcast traffic as scrambled MPEG-TS (IPTV)\n");
println!("profile: {TS_PACKET}-byte TS packets, PID 0x{VIDEO_PID:04x}, scrambled,",);
println!(
" one every {} ms ({} pps, ~{} kbps), fanned to {VIEWERS} viewers\n",
TS_INTERVAL.as_millis(),
1000 / TS_INTERVAL.as_millis(),
TS_PACKET * 8 * (1000 / TS_INTERVAL.as_millis() as usize) / 1000,
);
let mux: Arc<dyn CoverGenerator> = Arc::new(TsMuxer::new(VIDEO_PID));
let broadcaster = Node::new(ShapeConfig {
name: Some("broadcaster".into()),
listener_addr: Some("127.0.0.1:0".parse()?),
strategy: ShapingStrategy::Constant {
interval: TS_INTERVAL,
},
scope: ShapingScope::Global,
fanout: VIEWERS + 4,
frame_size: TS_PACKET,
cover_generator: Some(mux),
..Default::default()
});
let mut viewers = Vec::new();
for i in 0..VIEWERS {
let v = Node::new(ShapeConfig {
name: Some(format!("viewer-{i}")),
listener_addr: Some("127.0.0.1:0".parse()?),
strategy: ShapingStrategy::Constant {
interval: Duration::from_secs(10),
},
scope: ShapingScope::Global,
frame_size: TS_PACKET,
..Default::default()
});
viewers.push(v);
}
broadcaster.spawn().await?;
for v in &viewers {
v.spawn().await?;
}
tokio::time::sleep(Duration::from_millis(20)).await;
let mut viewer_addrs = Vec::new();
for v in &viewers {
let addr = v.local_addr().await?;
broadcaster.connect(addr).await?;
viewer_addrs.push(addr);
}
for addr in &viewer_addrs {
for _ in 0..50 {
if broadcaster.connected_peers().contains(addr) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
let mut rxs: Vec<_> = viewers.iter().map(Node::subscribe).collect();
let speaker = broadcaster.clone();
tokio::spawn(async move {
for line in [
&b"EMERGENCY BROADCAST: meet at dawn"[..],
&b"drop point is unchanged"[..],
&b"burn this after reading"[..],
] {
tokio::time::sleep(Duration::from_millis(500)).await;
let frame = build_ts_packet(Some(line));
let _ = speaker
.shaper()
.enqueue_raw(Lane::High, Target::Broadcast, frame);
}
});
tokio::time::sleep(Duration::from_millis(2200)).await;
println!("viewer-0's reassembled MPEG-TS stream (first packets):\n");
println!(
" {:<5} {:<6} {:<5} {:<6} verdict",
"pid", "scram", "cc", "pusi"
);
println!(" {}", "-".repeat(48));
let start = Instant::now();
let mut per_viewer = Vec::new();
for (vi, rx) in rxs.iter_mut().enumerate() {
let mut packets = 0usize;
let mut valid = 0usize;
let mut discontinuities = 0usize;
let mut pids = HashSet::new();
let mut last_cc: Option<u8> = None;
let mut decoded: Vec<String> = Vec::new();
let mut shown = 0usize;
loop {
match rx.try_recv() {
Ok(buf) => {
packets += 1;
if let Some((pid, cc, pusi, scrambling)) = parse_ts(&buf) {
valid += 1;
pids.insert(pid);
if let Some(prev) = last_cc
&& cc != (prev + 1) & 0x0f
{
discontinuities += 1;
}
last_cc = Some(cc);
let real = recover(&buf);
if let Some(ref m) = real {
decoded.push(String::from_utf8_lossy(m).into_owned());
}
if vi == 0 && shown < 10 {
println!(
" 0x{pid:03x} {:<6} {cc:<5} {:<6} TS ✓{}",
if scrambling != 0 { "scram" } else { "clear" },
if pusi { "start" } else { "" },
if real.is_some() { " ← real" } else { "" },
);
shown += 1;
}
}
}
Err(TryRecvError::Empty | TryRecvError::Closed) => break,
Err(TryRecvError::Lagged(_)) => continue,
}
}
per_viewer.push((packets, valid, discontinuities, pids.len(), decoded));
}
let secs = start.elapsed().as_secs_f64().max(2.2);
println!("\n=== what each viewer reassembled ===");
for (i, (packets, valid, disc, n_pids, decoded)) in per_viewer.iter().enumerate() {
println!(
" viewer-{i}: {packets} packets, {valid} valid TS, {disc} CC discontinuities, \
{n_pids} PID, {} real msgs",
decoded.len()
);
}
let total: usize = per_viewer.iter().map(|v| v.0).sum();
println!(
"\n aggregate bitrate : {:.1} kbps",
(total * TS_PACKET * 8) as f64 / secs / 1000.0
);
if let Some((_, _, _, _, decoded)) = per_viewer.first() {
println!(" messages every viewer decoded : {decoded:?}");
}
println!("\nTo a DPI box this is one PID of constant-bitrate scrambled MPEG-TS");
println!("fanned to {VIEWERS} viewers — a textbook IPTV multicast. Each viewer");
println!("sees a gap-free continuity counter; the secret messages were muxed");
println!("into the scrambled payload, indistinguishable from the noise.");
println!("\n--- Notes ---");
println!("* Global scope with fanout ≥ viewers is the multicast model: one");
println!(" packet per tick to every viewer, so each sees a gap-free CC. A");
println!(" smaller fanout (gossip) would deliver a random subset per tick and");
println!(" show CC discontinuities — realistic as TS 'packet loss', but not a");
println!(" clean single-source stream.");
println!("* The continuity counter is shared across viewers (one stream); the");
println!(" muxer's cover + finalize_real hooks both advance it, so real lane");
println!(" frames and comfort packets interleave under one coherent counter.");
broadcaster.shutdown().await;
for v in &viewers {
v.shutdown().await;
}
Ok(())
}