snapshot of https://github.com/pion/webrtc.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
REUSE Compliance Check / lint (push) Failing after 0s
Browser E2E / Test (push) Failing after 3s
Test / test (1.24) (push) Failing after 0s
Test / test-i386 (1.25) (push) Failing after 0s
Test / test-i386 (1.24) (push) Failing after 0s
Test / test-windows (1.25) (push) Failing after 0s
Test / test-windows (1.24) (push) Failing after 0s
Test / test (1.25) (push) Failing after 0s
Examples Tests / Test (push) Failing after 3s
Test / test-macos (1.25) (push) Failing after 0s
Test / test-macos (1.24) (push) Failing after 0s
Go mod tidy / tidy (push) Failing after 0s
Test / test-wasm (push) Failing after 0s
CodeQL / analyze (push) Failing after 1s
Fuzz / fuzz (push) Failing after 0s
Some checks failed
REUSE Compliance Check / lint (push) Failing after 0s
Browser E2E / Test (push) Failing after 3s
Test / test (1.24) (push) Failing after 0s
Test / test-i386 (1.25) (push) Failing after 0s
Test / test-i386 (1.24) (push) Failing after 0s
Test / test-windows (1.25) (push) Failing after 0s
Test / test-windows (1.24) (push) Failing after 0s
Test / test (1.25) (push) Failing after 0s
Examples Tests / Test (push) Failing after 3s
Test / test-macos (1.25) (push) Failing after 0s
Test / test-macos (1.24) (push) Failing after 0s
Go mod tidy / tidy (push) Failing after 0s
Test / test-wasm (push) Failing after 0s
CodeQL / analyze (push) Failing after 1s
Fuzz / fuzz (push) Failing after 0s
This commit is contained in:
19
examples/sctp-interleaving-inspect/README.md
Normal file
19
examples/sctp-interleaving-inspect/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# SCTP Interleaving Inspector
|
||||
|
||||
This browser example uses `pion/ice`, `pion/dtls`, `pion/sctp`, and
|
||||
`pion/datachannel` directly so it can intercept raw SCTP messages and parses them.
|
||||
|
||||
```sh
|
||||
go run .
|
||||
```
|
||||
|
||||
Open <http://localhost:8080> and click **Connect**. The page creates the
|
||||
browser offer automatically, connects to the Pion stack, and reports whether
|
||||
INIT/INIT-ACK advertised I-DATA chunk type 64, whether I-DATA was actually
|
||||
observed, and whether legacy DATA chunks were seen.
|
||||
|
||||
As I wrote this, firefox should have I-DATA enabled by default, for chrome, you'll need to run chrome with this flag:
|
||||
|
||||
```
|
||||
[chrome] -user-data-dir=/tmp/chrome-sctp-test --force-fieldtrials="WebRTC-DataChannel-Dcsctp/Enabled/WebRTC-DataChannelMessageInterleaving/Enabled/" http://localhost:8080
|
||||
```
|
||||
553
examples/sctp-interleaving-inspect/index.html
Normal file
553
examples/sctp-interleaving-inspect/index.html
Normal file
@@ -0,0 +1,553 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SCTP Interleaving Inspector</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
background: #f7f8fb;
|
||||
color: #1d2430;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 420px) 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
aside {
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #d8dde8;
|
||||
padding: 20px;
|
||||
}
|
||||
section {
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
margin: 22px 0 10px;
|
||||
text-transform: uppercase;
|
||||
color: #526070;
|
||||
}
|
||||
button, input {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
border: 1px solid #aeb7c6;
|
||||
background: #ffffff;
|
||||
color: #1d2430;
|
||||
border-radius: 6px;
|
||||
padding: 8px 11px;
|
||||
}
|
||||
button.primary {
|
||||
background: #1664d9;
|
||||
border-color: #1664d9;
|
||||
color: #fff;
|
||||
}
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.67;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
label {
|
||||
font-size: 13px;
|
||||
color: #3d4652;
|
||||
}
|
||||
input {
|
||||
min-width: 0;
|
||||
border: 1px solid #b9c2d0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border: 1px solid #d8dde8;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
.status strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
.status span {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.metric {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border: 1px solid #d8dde8;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
.metric strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
.metric span {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #3d4652;
|
||||
}
|
||||
.yes {
|
||||
color: #087443;
|
||||
}
|
||||
.no {
|
||||
color: #9a3412;
|
||||
}
|
||||
.unknown {
|
||||
color: #6b7280;
|
||||
}
|
||||
.packets {
|
||||
overflow: auto;
|
||||
border: 1px solid #d8dde8;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
max-height: 52vh;
|
||||
}
|
||||
.packet-grid {
|
||||
font-size: 13px;
|
||||
}
|
||||
.packet-header,
|
||||
.packet-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 96px 80px minmax(220px, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
.packet-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #f2f4f8;
|
||||
color: #526070;
|
||||
font-weight: 650;
|
||||
}
|
||||
.packet-row {
|
||||
border-top: 1px solid #e6e9f0;
|
||||
}
|
||||
.packet-cell {
|
||||
padding: 7px 8px;
|
||||
}
|
||||
.packet-meta {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
#log {
|
||||
border: 1px solid #d8dde8;
|
||||
border-radius: 6px;
|
||||
background: #0f1720;
|
||||
color: #dce7f7;
|
||||
padding: 10px;
|
||||
min-height: 150px;
|
||||
max-height: 32vh;
|
||||
overflow: auto;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
aside {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #d8dde8;
|
||||
}
|
||||
.packet-header,
|
||||
.packet-row {
|
||||
grid-template-columns: 44px 84px 70px minmax(160px, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<aside>
|
||||
<h1>SCTP Interleaving Inspector</h1>
|
||||
<div class="row">
|
||||
<button id="connect" class="primary">Connect</button>
|
||||
<button id="send" disabled>Send probe</button>
|
||||
<button id="bulk" disabled>Send bulk</button>
|
||||
<button id="pressure" disabled>Pressure test</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="message">Probe message</label>
|
||||
<input id="message" value="hello over data channel">
|
||||
</div>
|
||||
|
||||
<h2>Status</h2>
|
||||
<div class="status-grid">
|
||||
<div class="status"><strong>Connection</strong><span id="connection">idle</span></div>
|
||||
<div class="status"><strong>github.com/pion/sctp</strong><span id="sctpVersion">loading</span></div>
|
||||
<div class="status"><strong>Browser advertises I-DATA</strong><span id="rxAdvertised" class="unknown">unknown</span></div>
|
||||
<div class="status"><strong>Pion advertises I-DATA</strong><span id="txAdvertised" class="unknown">unknown</span></div>
|
||||
<div class="status"><strong>Browser sent I-DATA</strong><span id="rxIData" class="unknown">unknown</span></div>
|
||||
<div class="status"><strong>Pion sent I-DATA</strong><span id="txIData" class="unknown">unknown</span></div>
|
||||
<div class="status"><strong>Legacy DATA seen</strong><span id="legacyData" class="unknown">unknown</span></div>
|
||||
</div>
|
||||
|
||||
<h2>Pressure</h2>
|
||||
<div class="status-grid">
|
||||
<div class="metric"><strong>Bulk payload</strong><span id="pressureBulk">idle</span></div>
|
||||
<div class="metric"><strong>Control pings</strong><span id="pressurePings">idle</span></div>
|
||||
<div class="metric"><strong>Latency</strong><span id="pressureLatency">idle</span></div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<section>
|
||||
<h2>Plain SCTP packets</h2>
|
||||
<div class="packets">
|
||||
<div class="packet-grid">
|
||||
<div class="packet-header">
|
||||
<div class="packet-cell">#</div>
|
||||
<div class="packet-cell">Direction</div>
|
||||
<div class="packet-cell">Bytes</div>
|
||||
<div class="packet-cell">Chunks</div>
|
||||
</div>
|
||||
<div id="packets"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Log</h2>
|
||||
<pre id="log"></pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let pc;
|
||||
let control;
|
||||
let bulk;
|
||||
let probe;
|
||||
let packetCount = 0;
|
||||
let pressureRun = null;
|
||||
const sessionID = crypto.randomUUID();
|
||||
const state = {
|
||||
rxAdvertised: "unknown",
|
||||
txAdvertised: "unknown",
|
||||
rxIData: "unknown",
|
||||
txIData: "unknown",
|
||||
legacyData: "unknown",
|
||||
};
|
||||
|
||||
const eventSource = new EventSource(`/events?session=${encodeURIComponent(sessionID)}`);
|
||||
eventSource.onmessage = event => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "packet") {
|
||||
addPacket(msg);
|
||||
} else {
|
||||
log(`[${msg.kind}] ${msg.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
fetch("/status")
|
||||
.then(r => r.json())
|
||||
.then(status => {
|
||||
document.getElementById("sctpVersion").textContent = status.sctp;
|
||||
});
|
||||
|
||||
document.getElementById("connect").onclick = () => connect();
|
||||
document.getElementById("send").onclick = () => {
|
||||
const msg = document.getElementById("message").value;
|
||||
control.send(msg);
|
||||
probe.send(new Uint8Array([1, 2, 3, 4, 5]));
|
||||
log(`[browser] sent probe messages`);
|
||||
};
|
||||
document.getElementById("bulk").onclick = () => {
|
||||
const payload = new Uint8Array(512 * 1024);
|
||||
crypto.getRandomValues(payload.subarray(0, 65536));
|
||||
bulk.send(payload);
|
||||
control.send("bulk queued after binary payload");
|
||||
log(`[browser] sent ${payload.byteLength} byte bulk payload`);
|
||||
};
|
||||
document.getElementById("pressure").onclick = () => runPressureTest();
|
||||
|
||||
async function connect() {
|
||||
resetView();
|
||||
document.getElementById("connect").disabled = true;
|
||||
|
||||
try {
|
||||
pc = new RTCPeerConnection();
|
||||
pc.onconnectionstatechange = () => {
|
||||
document.getElementById("connection").textContent = pc.connectionState;
|
||||
log(`[browser] connection ${pc.connectionState}`);
|
||||
};
|
||||
pc.oniceconnectionstatechange = () => log(`[browser] ICE ${pc.iceConnectionState}`);
|
||||
|
||||
control = pc.createDataChannel("control");
|
||||
bulk = pc.createDataChannel("bulk");
|
||||
bulk.binaryType = "arraybuffer";
|
||||
probe = pc.createDataChannel("probe");
|
||||
for (const channel of [control, bulk, probe]) {
|
||||
channel.onopen = () => {
|
||||
log(`[browser] ${channel.label} open`);
|
||||
document.getElementById("send").disabled = false;
|
||||
document.getElementById("bulk").disabled = false;
|
||||
document.getElementById("pressure").disabled = false;
|
||||
};
|
||||
channel.onmessage = event => handleChannelMessage(channel, event);
|
||||
}
|
||||
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
await waitForIceGatheringComplete(pc);
|
||||
|
||||
const res = await fetch(`/offer?session=${encodeURIComponent(sessionID)}`, {
|
||||
method: "POST",
|
||||
headers: {"content-type": "application/sdp"},
|
||||
body: pc.localDescription.sdp,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
|
||||
const answer = await res.text();
|
||||
await pc.setRemoteDescription({type: "answer", sdp: answer});
|
||||
} catch (err) {
|
||||
log(`[error] ${err.message}`);
|
||||
document.getElementById("connect").disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function waitForIceGatheringComplete(peer) {
|
||||
if (peer.iceGatheringState === "complete") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
peer.addEventListener("icegatheringstatechange", () => {
|
||||
if (peer.iceGatheringState === "complete") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addPacket(packet) {
|
||||
packetCount += 1;
|
||||
for (const chunk of packet.chunks) {
|
||||
if (chunk.type === 1 || chunk.type === 2) {
|
||||
if (packet.direction === "rx") state.rxAdvertised = chunk.interleavingAdvertised ? "yes" : "no";
|
||||
if (packet.direction === "tx") state.txAdvertised = chunk.interleavingAdvertised ? "yes" : "no";
|
||||
}
|
||||
if ((chunk.type === 0 || chunk.type === 64) && packet.direction === "rx") {
|
||||
state.rxIData = chunk.type === 64 ? "yes" : "no";
|
||||
}
|
||||
if ((chunk.type === 0 || chunk.type === 64) && packet.direction === "tx") {
|
||||
state.txIData = chunk.type === 64 ? "yes" : "no";
|
||||
}
|
||||
if (chunk.type === 0) state.legacyData = "yes";
|
||||
if (chunk.type === 64 && state.legacyData === "unknown") state.legacyData = "no";
|
||||
}
|
||||
renderState();
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "packet-row";
|
||||
const chunks = packet.chunks.map(chunk => {
|
||||
const suffix = chunk.interleavingAdvertised ? " advertises I-DATA" : "";
|
||||
return `${chunk.name}(${chunk.type})${suffix}`;
|
||||
}).join(", ");
|
||||
row.innerHTML = `
|
||||
<div class="packet-cell packet-meta">${packetCount}</div>
|
||||
<div class="packet-cell packet-meta">${packet.direction}</div>
|
||||
<div class="packet-cell packet-meta">${packet.size}</div>
|
||||
<div class="packet-cell"><pre>${chunks}</pre></div>
|
||||
`;
|
||||
const packets = document.getElementById("packets");
|
||||
packets.prepend(row);
|
||||
while (packets.children.length > 600) {
|
||||
packets.lastElementChild.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function handleChannelMessage(channel, event) {
|
||||
if (channel === control && typeof event.data === "string" && event.data.startsWith("server-pressure:")) {
|
||||
recordServerPressureProbe(event.data);
|
||||
return;
|
||||
}
|
||||
if (channel === bulk && pressureRun && event.data instanceof ArrayBuffer) {
|
||||
pressureRun.bulkDone = true;
|
||||
pressureRun.bulkBytes = event.data.byteLength;
|
||||
setMetric("pressureBulk", `bulk complete: ${formatBytes(event.data.byteLength)}`);
|
||||
setTimeout(() => finishPressureTest(), 1000);
|
||||
return;
|
||||
}
|
||||
log(`[pion:${channel.label}] ${event.data}`);
|
||||
}
|
||||
|
||||
async function runPressureTest() {
|
||||
if (!control || !bulk || control.readyState !== "open" || bulk.readyState !== "open") {
|
||||
log("[pressure] channels are not open");
|
||||
return;
|
||||
}
|
||||
|
||||
const button = document.getElementById("pressure");
|
||||
button.disabled = true;
|
||||
const runID = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
pressureRun = {
|
||||
id: runID,
|
||||
received: 0,
|
||||
beforeBulk: 0,
|
||||
afterBulk: 0,
|
||||
bulkDone: false,
|
||||
bulkBytes: 0,
|
||||
startedAt: performance.now(),
|
||||
timeout: null,
|
||||
};
|
||||
|
||||
setMetric("pressureBulk", "starting server-to-browser bulk transfer");
|
||||
setMetric("pressurePings", "0 before bulk, 0 after bulk");
|
||||
setMetric("pressureLatency", "waiting for server probes");
|
||||
|
||||
const response = await fetch(
|
||||
`/server-pressure?session=${encodeURIComponent(sessionID)}&run=${encodeURIComponent(runID)}`,
|
||||
{method: "POST"}
|
||||
);
|
||||
if (!response.ok) {
|
||||
setMetric("pressureBulk", await response.text());
|
||||
button.disabled = false;
|
||||
pressureRun = null;
|
||||
return;
|
||||
}
|
||||
|
||||
log("[pressure] server is sending one throttled 2 MiB bulk message and control probes");
|
||||
pressureRun.timeout = setTimeout(() => {
|
||||
if (pressureRun && pressureRun.id === runID) {
|
||||
finishPressureTest();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function recordServerPressureProbe(message) {
|
||||
const parts = message.split(":");
|
||||
if (!pressureRun || parts.length !== 3 || parts[1] !== pressureRun.id) {
|
||||
log(`[pion:control] ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
pressureRun.received += 1;
|
||||
if (pressureRun.bulkDone) {
|
||||
pressureRun.afterBulk += 1;
|
||||
} else {
|
||||
pressureRun.beforeBulk += 1;
|
||||
}
|
||||
setMetric("pressurePings", `${pressureRun.beforeBulk} before bulk, ${pressureRun.afterBulk} after bulk`);
|
||||
renderPressureLatency();
|
||||
}
|
||||
|
||||
function finishPressureTest() {
|
||||
if (!pressureRun) {
|
||||
return;
|
||||
}
|
||||
if (pressureRun.timeout) {
|
||||
clearTimeout(pressureRun.timeout);
|
||||
}
|
||||
renderPressureLatency();
|
||||
const elapsed = ((performance.now() - pressureRun.startedAt) / 1000).toFixed(1);
|
||||
const result = pressureRun.beforeBulk > 0
|
||||
? `control survived: ${pressureRun.beforeBulk} probes arrived before bulk completed (${elapsed}s)`
|
||||
: `control blocked: no probes arrived before bulk completed or timeout (${elapsed}s)`;
|
||||
setMetric("pressureBulk", result);
|
||||
document.getElementById("pressure").disabled = false;
|
||||
pressureRun = null;
|
||||
}
|
||||
|
||||
function renderPressureLatency() {
|
||||
if (!pressureRun || pressureRun.received === 0) {
|
||||
setMetric("pressureLatency", "waiting");
|
||||
return;
|
||||
}
|
||||
const elapsed = ((performance.now() - pressureRun.startedAt) / 1000).toFixed(1);
|
||||
setMetric("pressureLatency", `${pressureRun.received} probes in ${elapsed}s`);
|
||||
}
|
||||
|
||||
function setMetric(id, value) {
|
||||
document.getElementById(id).textContent = value;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
if (value >= 1024 * 1024) {
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||
}
|
||||
if (value >= 1024) {
|
||||
return `${(value / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
return `${value} B`;
|
||||
}
|
||||
|
||||
function renderState() {
|
||||
setTriState("rxAdvertised", state.rxAdvertised);
|
||||
setTriState("txAdvertised", state.txAdvertised);
|
||||
setTriState("rxIData", state.rxIData);
|
||||
setTriState("txIData", state.txIData);
|
||||
setTriState("legacyData", state.legacyData);
|
||||
}
|
||||
|
||||
function setTriState(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
el.textContent = value;
|
||||
el.className = value;
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
packetCount = 0;
|
||||
Object.keys(state).forEach(key => state[key] = "unknown");
|
||||
renderState();
|
||||
document.getElementById("packets").textContent = "";
|
||||
document.getElementById("log").textContent = "";
|
||||
document.getElementById("connect").disabled = false;
|
||||
document.getElementById("send").disabled = true;
|
||||
document.getElementById("bulk").disabled = true;
|
||||
document.getElementById("pressure").disabled = true;
|
||||
pressureRun = null;
|
||||
setMetric("pressureBulk", "idle");
|
||||
setMetric("pressurePings", "idle");
|
||||
setMetric("pressureLatency", "idle");
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
const logEl = document.getElementById("log");
|
||||
logEl.textContent += `${message}\n`;
|
||||
// dont scroll if the log is not at the bottom :)
|
||||
if (logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight) {
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
871
examples/sctp-interleaving-inspect/main.go
Normal file
871
examples/sctp-interleaving-inspect/main.go
Normal file
@@ -0,0 +1,871 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// sctp-interleaving demonstrates how to inspect plaintext SCTP packets between
|
||||
// DTLS and SCTP while still interoperating with a browser RTCPeerConnection.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/datachannel"
|
||||
"github.com/pion/dtls/v3"
|
||||
"github.com/pion/dtls/v3/pkg/crypto/selfsign"
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/logging"
|
||||
"github.com/pion/sctp"
|
||||
"github.com/pion/sdp/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
listenAddr = ":8080"
|
||||
sctpPort = 5000
|
||||
maxMessageSize = 64 * 1024 * 1024
|
||||
outboundMTU = 1191
|
||||
readBufferSize = 20 * 1024 * 1024
|
||||
)
|
||||
|
||||
var (
|
||||
errPeerProvidedNoDTLSCertificate = errors.New("peer provided no DTLS certificate")
|
||||
errNoApplicationMediaSection = errors.New("offer has no application media section")
|
||||
errMissingICECredentials = errors.New("offer missing ICE credentials")
|
||||
errNoICECandidates = errors.New("offer has no ICE candidates; wait for ICE gathering to complete")
|
||||
errMissingDTLSFingerprint = errors.New("offer missing DTLS fingerprint")
|
||||
errUnsupportedFingerprint = errors.New("unsupported fingerprint algorithm")
|
||||
errRemoteFingerprintMismatch = errors.New("remote certificate fingerprint mismatch")
|
||||
errDataChannelsNotReady = errors.New("control and bulk channels are not ready")
|
||||
errTrafficShaperNotReady = errors.New("traffic shaper is not ready")
|
||||
)
|
||||
|
||||
func main() {
|
||||
events := newEventHub()
|
||||
|
||||
http.HandleFunc("/", func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
http.ServeFile(responseWriter, request, "./index.html")
|
||||
})
|
||||
http.HandleFunc("/events", events.serveHTTP)
|
||||
http.HandleFunc("/status", serveStatus)
|
||||
http.HandleFunc("/server-pressure", func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost {
|
||||
http.Error(responseWriter, "method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := request.URL.Query().Get("session")
|
||||
runID := request.URL.Query().Get("run")
|
||||
if err := events.startServerPressure(sessionID, runID); err != nil {
|
||||
http.Error(responseWriter, err.Error(), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
responseWriter.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
http.HandleFunc("/offer", func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost {
|
||||
http.Error(responseWriter, "method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
offer, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
http.Error(responseWriter, err.Error(), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := request.URL.Query().Get("session")
|
||||
answer, err := acceptBrowserOffer(request.Context(), sessionID, string(offer), events)
|
||||
if err != nil {
|
||||
events.publish(sessionID, logEvent("error", err.Error()))
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
responseWriter.Header().Set("Content-Type", "application/sdp")
|
||||
// The SDP answer is generated by this server from parsed offer fields.
|
||||
//nolint:gosec
|
||||
if _, err = io.WriteString(responseWriter, answer); err != nil {
|
||||
events.publish(sessionID, logEvent("error", err.Error()))
|
||||
}
|
||||
})
|
||||
|
||||
fmt.Printf("Listening on %s\n", listenAddr)
|
||||
//nolint:gosec
|
||||
if err := http.ListenAndServe(listenAddr, nil); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func acceptBrowserOffer(ctx context.Context, sessionID string, rawOffer string, events *eventHub) (string, error) {
|
||||
offer, err := parseOffer(rawOffer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cert, err := selfsign.GenerateSelfSigned()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
agent, localCandidates, err := gatherICECandidates(ctx, sessionID, events)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
localUfrag, localPwd, err := agent.GetLocalUserCredentials()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, candidate := range offer.candidates {
|
||||
if addErr := agent.AddRemoteCandidate(candidate); addErr != nil {
|
||||
return "", addErr
|
||||
}
|
||||
}
|
||||
|
||||
answer := buildAnswer(localUfrag, localPwd, localCandidates, cert, offer)
|
||||
|
||||
go func() {
|
||||
stackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := runStack(stackCtx, agent, offer, cert, sessionID, events); err != nil {
|
||||
events.publish(sessionID, logEvent("error", err.Error()))
|
||||
}
|
||||
}()
|
||||
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func gatherICECandidates(ctx context.Context, sessionID string, events *eventHub) (*ice.Agent, []ice.Candidate, error) {
|
||||
agent, err := ice.NewAgentWithOptions(
|
||||
ice.WithNetworkTypes([]ice.NetworkType{ice.NetworkTypeUDP4, ice.NetworkTypeUDP6}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
candidates []ice.Candidate
|
||||
done = make(chan struct{})
|
||||
doneOnce sync.Once
|
||||
)
|
||||
|
||||
if err = agent.OnCandidate(func(c ice.Candidate) {
|
||||
if c == nil {
|
||||
doneOnce.Do(func() { close(done) })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
candidates = append(candidates, c)
|
||||
mu.Unlock()
|
||||
events.publish(sessionID, logEvent("ice-candidate", c.Marshal()))
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err = agent.GatherCandidates(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
return nil, nil, ctx.Err()
|
||||
case <-time.After(3 * time.Second):
|
||||
events.publish(sessionID, logEvent("warn", "ICE gathering timed out; continuing with gathered candidates"))
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
return agent, append([]ice.Candidate(nil), candidates...), nil
|
||||
}
|
||||
|
||||
func runStack(
|
||||
ctx context.Context,
|
||||
agent *ice.Agent,
|
||||
offer browserOffer,
|
||||
cert tls.Certificate,
|
||||
sessionID string,
|
||||
events *eventHub,
|
||||
) error {
|
||||
events.publish(sessionID, logEvent("ice", "starting controlled ICE"))
|
||||
iceConn, err := agent.Accept(ctx, offer.iceUfrag, offer.icePwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events.publish(sessionID, logEvent("ice", "connected: "+iceConn.RemoteAddr().String()))
|
||||
|
||||
packetConn := &connectedPacketConn{Conn: iceConn, remote: iceConn.RemoteAddr()}
|
||||
dtlsConn, err := dtls.ServerWithOptions(
|
||||
packetConn,
|
||||
packetConn.remote,
|
||||
dtls.WithCertificates(cert),
|
||||
dtls.WithSRTPProtectionProfiles(
|
||||
dtls.SRTP_AEAD_AES_256_GCM,
|
||||
dtls.SRTP_AEAD_AES_128_GCM,
|
||||
dtls.SRTP_AES128_CM_HMAC_SHA1_80,
|
||||
),
|
||||
dtls.WithSupportedProtocols("webrtc"),
|
||||
dtls.WithClientAuth(dtls.RequireAnyClientCert),
|
||||
dtls.WithInsecureSkipVerify(true),
|
||||
dtls.WithVerifyPeerCertificate(func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||
if len(rawCerts) == 0 {
|
||||
return errPeerProvidedNoDTLSCertificate
|
||||
}
|
||||
|
||||
return verifyFingerprint(rawCerts[0], offer.fingerprint)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events.publish(sessionID, logEvent("dtls", "waiting for browser DTLS handshake"))
|
||||
if err = dtlsConn.Handshake(); err != nil {
|
||||
return err
|
||||
}
|
||||
if state, ok := dtlsConn.ConnectionState(); ok {
|
||||
events.publish(sessionID, logEvent("dtls", "connected; ALPN="+state.NegotiatedProtocol))
|
||||
} else {
|
||||
events.publish(sessionID, logEvent("dtls", "connected"))
|
||||
}
|
||||
|
||||
shaper := events.registerShaper(sessionID)
|
||||
defer events.unregisterShaper(sessionID, shaper)
|
||||
tap := &sctpTapConn{
|
||||
Conn: dtlsConn,
|
||||
shaper: shaper,
|
||||
inspect: func(direction string, packet []byte) {
|
||||
events.publish(sessionID, packetEvent(direction, packet))
|
||||
},
|
||||
}
|
||||
|
||||
assoc, err := sctp.ClientWithOptions(
|
||||
sctp.WithNetConn(tap),
|
||||
sctp.WithMTU(outboundMTU),
|
||||
sctp.WithMaxMessageSize(maxMessageSize),
|
||||
sctp.WithLoggerFactory(logging.NewDefaultLoggerFactory()),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events.publish(sessionID, logEvent("sctp", "association established"))
|
||||
|
||||
for {
|
||||
dc, err := datachannel.Accept(assoc, &datachannel.Config{
|
||||
LoggerFactory: logging.NewDefaultLoggerFactory(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
events.publish(sessionID, logEvent("datachannel", "accepted "+dc.Label))
|
||||
events.registerDataChannel(sessionID, dc)
|
||||
go echoDataChannel(dc, sessionID, events)
|
||||
}
|
||||
}
|
||||
|
||||
func echoDataChannel(dc *datachannel.DataChannel, sessionID string, events *eventHub) {
|
||||
defer events.unregisterDataChannel(sessionID, dc)
|
||||
|
||||
buffer := make([]byte, readBufferSize)
|
||||
for {
|
||||
n, isString, err := dc.ReadDataChannel(buffer)
|
||||
if err != nil {
|
||||
events.publish(sessionID, logEvent("datachannel", dc.Label+" closed: "+err.Error()))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
kind := "binary"
|
||||
if isString {
|
||||
kind = "text"
|
||||
}
|
||||
events.publish(sessionID, logEvent("message", fmt.Sprintf("%s received %d byte %s message", dc.Label, n, kind)))
|
||||
|
||||
response := fmt.Appendf(nil, "echo from Pion on %s: %d bytes", dc.Label, n)
|
||||
if isString {
|
||||
response = append([]byte(nil), buffer[:n]...)
|
||||
}
|
||||
|
||||
if _, err = dc.WriteDataChannel(response, true); err != nil {
|
||||
events.publish(sessionID, logEvent("datachannel", dc.Label+" echo failed: "+err.Error()))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type browserOffer struct {
|
||||
raw *sdp.SessionDescription
|
||||
media *sdp.MediaDescription
|
||||
iceUfrag string
|
||||
icePwd string
|
||||
fingerprint fingerprint
|
||||
candidates []ice.Candidate
|
||||
}
|
||||
|
||||
type fingerprint struct {
|
||||
algorithm string
|
||||
value string
|
||||
}
|
||||
|
||||
func parseOffer(rawOffer string) (browserOffer, error) {
|
||||
var desc sdp.SessionDescription
|
||||
if err := desc.UnmarshalString(rawOffer); err != nil {
|
||||
return browserOffer{}, err
|
||||
}
|
||||
|
||||
var media *sdp.MediaDescription
|
||||
for _, md := range desc.MediaDescriptions {
|
||||
if md.MediaName.Media == "application" {
|
||||
media = md
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if media == nil {
|
||||
return browserOffer{}, errNoApplicationMediaSection
|
||||
}
|
||||
|
||||
iceUfrag := attr(&desc, media, "ice-ufrag")
|
||||
icePwd := attr(&desc, media, "ice-pwd")
|
||||
if iceUfrag == "" || icePwd == "" {
|
||||
return browserOffer{}, errMissingICECredentials
|
||||
}
|
||||
|
||||
fp, err := parseFingerprint(attr(&desc, media, "fingerprint"))
|
||||
if err != nil {
|
||||
return browserOffer{}, err
|
||||
}
|
||||
|
||||
candidates, err := parseCandidates(media)
|
||||
if err != nil {
|
||||
return browserOffer{}, err
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return browserOffer{}, errNoICECandidates
|
||||
}
|
||||
|
||||
return browserOffer{
|
||||
raw: &desc,
|
||||
media: media,
|
||||
iceUfrag: iceUfrag,
|
||||
icePwd: icePwd,
|
||||
fingerprint: fp,
|
||||
candidates: candidates,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func attr(desc *sdp.SessionDescription, media *sdp.MediaDescription, key string) string {
|
||||
if value, ok := media.Attribute(key); ok {
|
||||
return value
|
||||
}
|
||||
value, _ := desc.Attribute(key)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func parseFingerprint(raw string) (fingerprint, error) {
|
||||
parts := strings.Fields(raw)
|
||||
if len(parts) != 2 {
|
||||
return fingerprint{}, errMissingDTLSFingerprint
|
||||
}
|
||||
if !strings.EqualFold(parts[0], "sha-256") {
|
||||
return fingerprint{}, fmt.Errorf("%w: %q", errUnsupportedFingerprint, parts[0])
|
||||
}
|
||||
|
||||
return fingerprint{algorithm: strings.ToLower(parts[0]), value: strings.ToUpper(parts[1])}, nil
|
||||
}
|
||||
|
||||
func parseCandidates(media *sdp.MediaDescription) ([]ice.Candidate, error) {
|
||||
var candidates []ice.Candidate
|
||||
for _, attribute := range media.Attributes {
|
||||
if attribute.Key != "candidate" {
|
||||
continue
|
||||
}
|
||||
|
||||
candidate, err := ice.UnmarshalCandidate(attribute.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func buildAnswer(
|
||||
localUfrag string,
|
||||
localPwd string,
|
||||
candidates []ice.Candidate,
|
||||
cert tls.Certificate,
|
||||
offer browserOffer,
|
||||
) string {
|
||||
fp := certificateFingerprint(cert)
|
||||
mid := attr(offer.raw, offer.media, "mid")
|
||||
if mid == "" {
|
||||
mid = "0"
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
"v=0",
|
||||
"o=- " + strconv.FormatInt(time.Now().UnixNano(), 10) + " 0 IN IP4 127.0.0.1",
|
||||
"s=-",
|
||||
"t=0 0",
|
||||
"a=group:BUNDLE " + mid,
|
||||
"a=msid-semantic: WMS",
|
||||
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel",
|
||||
"c=IN IP4 0.0.0.0",
|
||||
"a=mid:" + mid,
|
||||
"a=sendrecv",
|
||||
"a=ice-ufrag:" + localUfrag,
|
||||
"a=ice-pwd:" + localPwd,
|
||||
"a=ice-options:trickle",
|
||||
"a=fingerprint:sha-256 " + fp,
|
||||
"a=setup:passive",
|
||||
"a=sctp-port:" + strconv.Itoa(sctpPort),
|
||||
"a=max-message-size:" + strconv.Itoa(maxMessageSize),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
lines = append(lines, "a="+candidate.Marshal())
|
||||
}
|
||||
lines = append(lines, "a=end-of-candidates", "")
|
||||
|
||||
return strings.Join(lines, "\r\n")
|
||||
}
|
||||
|
||||
func certificateFingerprint(cert tls.Certificate) string {
|
||||
sum := sha256.Sum256(cert.Certificate[0])
|
||||
encoded := strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
pairs := make([]string, 0, len(sum))
|
||||
for offset := 0; offset < len(encoded); offset += 2 {
|
||||
pairs = append(pairs, encoded[offset:offset+2])
|
||||
}
|
||||
|
||||
return strings.Join(pairs, ":")
|
||||
}
|
||||
|
||||
func verifyFingerprint(rawCert []byte, expected fingerprint) error {
|
||||
if !strings.EqualFold(expected.algorithm, "sha-256") {
|
||||
return fmt.Errorf("%w: %q", errUnsupportedFingerprint, expected.algorithm)
|
||||
}
|
||||
sum := sha256.Sum256(rawCert)
|
||||
actual := strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
expectedValue := strings.ToUpper(strings.ReplaceAll(expected.value, ":", ""))
|
||||
if actual != expectedValue {
|
||||
return fmt.Errorf("%w: got %s expected %s", errRemoteFingerprintMismatch, actual, expectedValue)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type connectedPacketConn struct {
|
||||
net.Conn
|
||||
remote net.Addr
|
||||
}
|
||||
|
||||
func (c *connectedPacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
|
||||
n, err := c.Conn.Read(p)
|
||||
|
||||
return n, c.remote, err
|
||||
}
|
||||
|
||||
func (c *connectedPacketConn) WriteTo(p []byte, _ net.Addr) (int, error) {
|
||||
return c.Conn.Write(p)
|
||||
}
|
||||
|
||||
func (c *connectedPacketConn) LocalAddr() net.Addr {
|
||||
return c.Conn.LocalAddr()
|
||||
}
|
||||
|
||||
type sctpTapConn struct {
|
||||
net.Conn
|
||||
inspect func(direction string, packet []byte)
|
||||
shaper *trafficShaper
|
||||
}
|
||||
|
||||
func (c *sctpTapConn) Read(p []byte) (int, error) {
|
||||
n, err := c.Conn.Read(p)
|
||||
if n > 0 && c.inspect != nil {
|
||||
c.inspect("rx", append([]byte(nil), p[:n]...))
|
||||
}
|
||||
if n > 0 && c.shaper != nil {
|
||||
c.shaper.throttle(n)
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *sctpTapConn) Write(p []byte) (int, error) {
|
||||
if len(p) > 0 && c.inspect != nil {
|
||||
c.inspect("tx", append([]byte(nil), p...))
|
||||
}
|
||||
|
||||
n, err := c.Conn.Write(p)
|
||||
if n > 0 && c.shaper != nil {
|
||||
c.shaper.throttle(n)
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
type eventHub struct {
|
||||
mu sync.Mutex
|
||||
clients map[string]map[chan []byte]struct{}
|
||||
shapers map[string]*trafficShaper
|
||||
connections map[string]map[string]*datachannel.DataChannel
|
||||
}
|
||||
|
||||
func newEventHub() *eventHub {
|
||||
return &eventHub{
|
||||
clients: map[string]map[chan []byte]struct{}{},
|
||||
shapers: map[string]*trafficShaper{},
|
||||
connections: map[string]map[string]*datachannel.DataChannel{},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *eventHub) serveHTTP(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
responseWriter.Header().Set("Content-Type", "text/event-stream")
|
||||
responseWriter.Header().Set("Cache-Control", "no-cache")
|
||||
responseWriter.Header().Set("Connection", "keep-alive")
|
||||
|
||||
flusher, ok := responseWriter.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(responseWriter, "streaming unsupported", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := request.URL.Query().Get("session")
|
||||
ch := make(chan []byte, 64)
|
||||
h.mu.Lock()
|
||||
if h.clients[sessionID] == nil {
|
||||
h.clients[sessionID] = map[chan []byte]struct{}{}
|
||||
}
|
||||
h.clients[sessionID][ch] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
delete(h.clients[sessionID], ch)
|
||||
if len(h.clients[sessionID]) == 0 {
|
||||
delete(h.clients, sessionID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-request.Context().Done():
|
||||
return
|
||||
case msg := <-ch:
|
||||
_, _ = fmt.Fprintf(responseWriter, "data: %s\n\n", msg)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *eventHub) publish(sessionID string, event any) {
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for ch := range h.clients[sessionID] {
|
||||
select {
|
||||
case ch <- payload:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *eventHub) registerShaper(sessionID string) *trafficShaper {
|
||||
shaper := &trafficShaper{bytesPerSecond: 128 * 1024}
|
||||
|
||||
h.mu.Lock()
|
||||
h.shapers[sessionID] = shaper
|
||||
h.mu.Unlock()
|
||||
|
||||
return shaper
|
||||
}
|
||||
|
||||
func (h *eventHub) unregisterShaper(sessionID string, shaper *trafficShaper) {
|
||||
h.mu.Lock()
|
||||
if h.shapers[sessionID] == shaper {
|
||||
delete(h.shapers, sessionID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *eventHub) registerDataChannel(sessionID string, dc *datachannel.DataChannel) {
|
||||
h.mu.Lock()
|
||||
if h.connections[sessionID] == nil {
|
||||
h.connections[sessionID] = map[string]*datachannel.DataChannel{}
|
||||
}
|
||||
h.connections[sessionID][dc.Label] = dc
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *eventHub) unregisterDataChannel(sessionID string, dc *datachannel.DataChannel) {
|
||||
h.mu.Lock()
|
||||
if h.connections[sessionID][dc.Label] == dc {
|
||||
delete(h.connections[sessionID], dc.Label)
|
||||
}
|
||||
if len(h.connections[sessionID]) == 0 {
|
||||
delete(h.connections, sessionID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *eventHub) startServerPressure(sessionID string, runID string) error {
|
||||
h.mu.Lock()
|
||||
channels := h.connections[sessionID]
|
||||
control := channels["control"]
|
||||
bulk := channels["bulk"]
|
||||
shaper := h.shapers[sessionID]
|
||||
h.mu.Unlock()
|
||||
|
||||
if control == nil || bulk == nil {
|
||||
return errDataChannelsNotReady
|
||||
}
|
||||
if shaper == nil {
|
||||
return errTrafficShaperNotReady
|
||||
}
|
||||
if runID == "" {
|
||||
runID = strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
}
|
||||
|
||||
go h.runServerPressure(sessionID, runID, shaper, control, bulk)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *eventHub) runServerPressure(
|
||||
sessionID string,
|
||||
runID string,
|
||||
shaper *trafficShaper,
|
||||
control *datachannel.DataChannel,
|
||||
bulk *datachannel.DataChannel,
|
||||
) {
|
||||
shaper.setEnabled(true)
|
||||
defer shaper.setEnabled(false)
|
||||
|
||||
const (
|
||||
bulkSize = 2 * 1024 * 1024
|
||||
probeDuration = 25 * time.Second
|
||||
)
|
||||
h.publish(sessionID, logEvent("pressure", "server queueing one 2 MiB bulk message and control probes"))
|
||||
|
||||
payload := make([]byte, bulkSize)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
|
||||
if _, err := bulk.WriteDataChannel(payload, false); err != nil {
|
||||
h.publish(sessionID, logEvent("pressure", "server bulk queue failed: "+err.Error()))
|
||||
|
||||
return
|
||||
}
|
||||
h.publish(sessionID, logEvent("pressure", "server bulk queued; sending control probes while it drains"))
|
||||
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
deadline := time.NewTimer(probeDuration)
|
||||
defer deadline.Stop()
|
||||
|
||||
seq := 0
|
||||
for {
|
||||
select {
|
||||
case <-deadline.C:
|
||||
h.publish(sessionID, logEvent("pressure", fmt.Sprintf("server pressure probes complete: %d sent", seq)))
|
||||
|
||||
return
|
||||
case <-ticker.C:
|
||||
msg := fmt.Appendf(nil, "server-pressure:%s:%d", runID, seq)
|
||||
if _, err := control.WriteDataChannel(msg, true); err != nil {
|
||||
h.publish(sessionID, logEvent("pressure", "server control probe failed: "+err.Error()))
|
||||
|
||||
return
|
||||
}
|
||||
seq++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type trafficShaper struct {
|
||||
mu sync.Mutex
|
||||
enabled bool
|
||||
bytesPerSecond int
|
||||
}
|
||||
|
||||
func (s *trafficShaper) setEnabled(enabled bool) {
|
||||
s.mu.Lock()
|
||||
s.enabled = enabled
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *trafficShaper) throttle(n int) {
|
||||
s.mu.Lock()
|
||||
enabled := s.enabled
|
||||
bytesPerSecond := s.bytesPerSecond
|
||||
s.mu.Unlock()
|
||||
|
||||
if !enabled || bytesPerSecond <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(n) * time.Second / time.Duration(bytesPerSecond))
|
||||
}
|
||||
|
||||
func logEvent(kind, message string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "log",
|
||||
"kind": kind,
|
||||
"message": message,
|
||||
}
|
||||
}
|
||||
|
||||
func packetEvent(direction string, raw []byte) map[string]any {
|
||||
chunks := parseSCTPChunks(raw)
|
||||
event := map[string]any{
|
||||
"type": "packet",
|
||||
"direction": direction,
|
||||
"size": len(raw),
|
||||
"chunks": chunks,
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
type sctpChunkView struct {
|
||||
Type uint8 `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Length uint16 `json:"length"`
|
||||
InterleavingAdvertised bool `json:"interleavingAdvertised,omitempty"`
|
||||
}
|
||||
|
||||
func parseSCTPChunks(raw []byte) []sctpChunkView {
|
||||
if len(raw) < 12 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var chunks []sctpChunkView
|
||||
for offset := 12; offset+4 <= len(raw); {
|
||||
chunkType := raw[offset]
|
||||
chunkLength := int(raw[offset+2])<<8 | int(raw[offset+3])
|
||||
if chunkLength < 4 || offset+chunkLength > len(raw) {
|
||||
break
|
||||
}
|
||||
|
||||
chunk := sctpChunkView{
|
||||
Type: chunkType,
|
||||
Name: chunkTypeName(chunkType),
|
||||
Length: uint16(chunkLength), //nolint:gosec // chunkLength is uint16 on the wire.
|
||||
}
|
||||
if chunkType == 1 || chunkType == 2 {
|
||||
chunk.InterleavingAdvertised = chunkAdvertisesInterleaving(raw[offset : offset+chunkLength])
|
||||
}
|
||||
chunks = append(chunks, chunk)
|
||||
|
||||
offset += (chunkLength + 3) &^ 3
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
func chunkAdvertisesInterleaving(chunk []byte) bool {
|
||||
const (
|
||||
initValueLength = 16
|
||||
paramHeaderLength = 4
|
||||
paramTypeSupportedExtensions = 0x8008
|
||||
chunkTypeInterleavedPayloadData = 64
|
||||
)
|
||||
|
||||
paramsOffset := 4 + initValueLength
|
||||
for offset := paramsOffset; offset+paramHeaderLength <= len(chunk); {
|
||||
paramType := int(chunk[offset])<<8 | int(chunk[offset+1])
|
||||
paramLength := int(chunk[offset+2])<<8 | int(chunk[offset+3])
|
||||
if paramLength < paramHeaderLength || offset+paramLength > len(chunk) {
|
||||
return false
|
||||
}
|
||||
|
||||
if paramType == paramTypeSupportedExtensions {
|
||||
if slices.Contains(chunk[offset+paramHeaderLength:offset+paramLength], chunkTypeInterleavedPayloadData) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
offset += (paramLength + 3) &^ 3
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func chunkTypeName(chunkType uint8) string {
|
||||
names := map[uint8]string{
|
||||
0: "DATA",
|
||||
1: "INIT",
|
||||
2: "INIT-ACK",
|
||||
3: "SACK",
|
||||
10: "COOKIE-ECHO",
|
||||
11: "COOKIE-ACK",
|
||||
64: "I-DATA",
|
||||
130: "RECONFIG",
|
||||
192: "FORWARD-TSN",
|
||||
}
|
||||
if name, ok := names[chunkType]; ok {
|
||||
return name
|
||||
}
|
||||
|
||||
return "chunk-" + strconv.Itoa(int(chunkType))
|
||||
}
|
||||
|
||||
func serveStatus(responseWriter http.ResponseWriter, _ *http.Request) {
|
||||
status := map[string]string{"sctp": "(unknown)"}
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
for _, dep := range info.Deps {
|
||||
if dep.Path == "github.com/pion/sctp" {
|
||||
status["sctp"] = dep.Version
|
||||
if dep.Replace != nil {
|
||||
status["sctp"] = dep.Replace.Path + " " + dep.Replace.Version
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
responseWriter.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(responseWriter).Encode(status); err != nil {
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user