mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 14:45:52 +00:00
Compare commits
3 Commits
fix/readme
...
fix/e2e-fl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1c0b4849 | ||
|
|
b47571c7f0 | ||
|
|
5bb9bc146e |
68
.github/workflows/deploy.yml
vendored
68
.github/workflows/deploy.yml
vendored
@@ -10,11 +10,6 @@ on:
|
||||
- 'docs/**'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'LICENSE'
|
||||
- '.gitignore'
|
||||
- 'docs/**'
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -42,8 +37,23 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Skip if docs-only change
|
||||
id: docs-check
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
|
||||
NON_DOCS=$(echo "$CHANGED" | grep -cvE '\.(md)$|^LICENSE$|^\.gitignore$|^docs/' || true)
|
||||
if [ "$NON_DOCS" -eq 0 ]; then
|
||||
echo "docs_only=true" >> $GITHUB_OUTPUT
|
||||
echo "📄 Docs-only PR — skipping heavy CI"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Set up Go 1.22
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.22'
|
||||
@@ -52,6 +62,7 @@ jobs:
|
||||
cmd/ingestor/go.sum
|
||||
|
||||
- name: Build and test Go server (with coverage)
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
cd cmd/server
|
||||
@@ -61,6 +72,7 @@ jobs:
|
||||
go tool cover -func=server-coverage.out | tail -1
|
||||
|
||||
- name: Build and test Go ingestor (with coverage)
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
cd cmd/ingestor
|
||||
@@ -70,6 +82,7 @@ jobs:
|
||||
go tool cover -func=ingestor-coverage.out | tail -1
|
||||
|
||||
- name: Verify proto syntax (all .proto files compile)
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
run: |
|
||||
set -e
|
||||
echo "Installing protoc..."
|
||||
@@ -84,7 +97,7 @@ jobs:
|
||||
echo "✅ All .proto files are syntactically valid"
|
||||
|
||||
- name: Generate Go coverage badges
|
||||
if: always()
|
||||
if: always() && steps.docs-check.outputs.docs_only != 'true'
|
||||
run: |
|
||||
mkdir -p .badges
|
||||
|
||||
@@ -144,21 +157,39 @@ jobs:
|
||||
node-test:
|
||||
name: "🧪 Node.js Tests"
|
||||
runs-on: [self-hosted, Linux]
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 2
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Skip if docs-only change
|
||||
id: docs-check
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
|
||||
NON_DOCS=$(echo "$CHANGED" | grep -cvE '\.(md)$|^LICENSE$|^\.gitignore$|^docs/' || true)
|
||||
if [ "$NON_DOCS" -eq 0 ]; then
|
||||
echo "docs_only=true" >> $GITHUB_OUTPUT
|
||||
echo "📄 Docs-only PR — skipping heavy CI"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Set up Node.js 22
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install npm dependencies
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
run: npm ci --production=false
|
||||
|
||||
- name: Detect changed files
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
id: changes
|
||||
run: |
|
||||
BACKEND=$(git diff --name-only HEAD~1 | grep -cE '^(server|db|decoder|packet-store|server-helpers|iata-coords)\.js$' || true)
|
||||
@@ -174,7 +205,7 @@ jobs:
|
||||
echo "Changes: backend=$BACKEND frontend=$FRONTEND tests=$TESTS ci=$CI"
|
||||
|
||||
- name: Run backend tests with coverage
|
||||
if: steps.changes.outputs.backend == 'true'
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.backend == 'true'
|
||||
run: |
|
||||
npx c8 --reporter=text-summary --reporter=text sh test-all.sh 2>&1 | tee test-output.txt
|
||||
|
||||
@@ -192,11 +223,11 @@ jobs:
|
||||
echo "## Backend: ${TOTAL_PASS} tests, ${BE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Run backend tests (quick, no coverage)
|
||||
if: steps.changes.outputs.backend == 'false'
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.backend == 'false'
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Install Playwright browser
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||
run: |
|
||||
# Install chromium (skips download if already cached on self-hosted runner)
|
||||
npx playwright install chromium 2>/dev/null || true
|
||||
@@ -204,11 +235,11 @@ jobs:
|
||||
npx playwright install-deps chromium 2>/dev/null || true
|
||||
|
||||
- name: Instrument frontend JS for coverage
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||
run: sh scripts/instrument-frontend.sh
|
||||
|
||||
- name: Start instrumented test server on port 13581
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||
run: |
|
||||
# Kill any stale server on 13581
|
||||
fuser -k 13581/tcp 2>/dev/null || true
|
||||
@@ -231,8 +262,12 @@ jobs:
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Seed test data for Playwright
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||
run: BASE_URL=http://localhost:13581 node tools/seed-test-data.js
|
||||
|
||||
- name: Run Playwright E2E + coverage collection concurrently
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||
run: |
|
||||
# Run E2E tests and coverage collection in parallel — both use the same server
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt &
|
||||
@@ -250,7 +285,7 @@ jobs:
|
||||
true
|
||||
|
||||
- name: Generate frontend coverage badges
|
||||
if: always() && steps.changes.outputs.frontend == 'true'
|
||||
if: always() && steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||
run: |
|
||||
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1)
|
||||
|
||||
@@ -269,7 +304,7 @@ jobs:
|
||||
echo "{\"schemaVersion\":1,\"label\":\"frontend tests\",\"message\":\"${E2E_PASS:-0} E2E passed\",\"color\":\"brightgreen\"}" > .badges/frontend-tests.json
|
||||
|
||||
- name: Stop test server
|
||||
if: always() && steps.changes.outputs.frontend == 'true'
|
||||
if: always() && steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||
run: |
|
||||
if [ -f .server.pid ]; then
|
||||
kill $(cat .server.pid) 2>/dev/null || true
|
||||
@@ -278,7 +313,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run frontend E2E (quick, no coverage)
|
||||
if: steps.changes.outputs.frontend == 'false'
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'false'
|
||||
run: |
|
||||
fuser -k 13581/tcp 2>/dev/null || true
|
||||
PORT=13581 node server.js &
|
||||
@@ -288,6 +323,7 @@ jobs:
|
||||
curl -sf http://localhost:13581/api/stats > /dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
BASE_URL=http://localhost:13581 node tools/seed-test-data.js || true
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
> High-performance mesh network analyzer powered by Go. Sub-millisecond packet queries, ~300 MB memory for 56K+ packets, real-time WebSocket broadcast, full channel decryption.
|
||||
|
||||
Self-hosted, open-source MeshCore packet analyzer — a community alternative to the closed-source `analyzer.letsmesh.net`. Collects MeshCore packets via MQTT, decodes them in real time, and presents a full web UI with live packet feed, interactive maps, channel chat, packet tracing, and per-node analytics.
|
||||
Self-hosted, open-source MeshCore packet analyzer. Collects MeshCore packets via MQTT, decodes them in real time, and presents a full web UI with live packet feed, interactive maps, channel chat, packet tracing, and per-node analytics.
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
|
||||
@@ -10,13 +10,21 @@ const GO_BASE = process.env.GO_BASE_URL || ''; // e.g. https://analyzer.00id.ne
|
||||
const results = [];
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
results.push({ name, pass: true });
|
||||
console.log(` \u2705 ${name}`);
|
||||
} catch (err) {
|
||||
results.push({ name, pass: false, error: err.message });
|
||||
console.log(` \u274c ${name}: ${err.message}`);
|
||||
const MAX_RETRIES = 2;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await fn();
|
||||
results.push({ name, pass: true });
|
||||
console.log(` \u2705 ${name}${attempt > 1 ? ` (retry ${attempt - 1})` : ''}`);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
console.log(` \u26a0\ufe0f ${name}: ${err.message} (retrying...)`);
|
||||
continue;
|
||||
}
|
||||
results.push({ name, pass: false, error: err.message });
|
||||
console.log(` \u274c ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +332,9 @@ async function run() {
|
||||
|
||||
// Test: Packets groupByHash toggle changes view
|
||||
await test('Packets groupByHash toggle works', async () => {
|
||||
await page.waitForSelector('table tbody tr');
|
||||
// Fresh navigation to ensure clean state
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('table tbody tr', { timeout: 15000 });
|
||||
const groupBtn = await page.$('#fGroup');
|
||||
assert(groupBtn, 'Group by hash button (#fGroup) not found');
|
||||
// Check initial state (default is grouped/active)
|
||||
@@ -354,10 +364,17 @@ async function run() {
|
||||
await test('Packets clicking row shows detail pane', async () => {
|
||||
// Fresh navigation to avoid stale row references from previous test
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
// Wait for table rows AND initial API data to settle
|
||||
await page.waitForSelector('table tbody tr[data-action]', { timeout: 15000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
const firstRow = await page.$('table tbody tr[data-action]');
|
||||
assert(firstRow, 'No clickable packet rows found');
|
||||
await firstRow.click();
|
||||
// Click the row and wait for the /packets/{hash} API response
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(resp => resp.url().includes('/packets/') && resp.status() === 200, { timeout: 15000 }),
|
||||
firstRow.click(),
|
||||
]);
|
||||
assert(response, 'API response for packet detail not received');
|
||||
await page.waitForFunction(() => {
|
||||
const panel = document.getElementById('pktRight');
|
||||
return panel && !panel.classList.contains('empty');
|
||||
@@ -375,12 +392,16 @@ async function run() {
|
||||
if (!pktRight) {
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('table tbody tr[data-action]', { timeout: 15000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
const panelOpen = await page.$eval('#pktRight', el => !el.classList.contains('empty'));
|
||||
if (!panelOpen) {
|
||||
const firstRow = await page.$('table tbody tr[data-action]');
|
||||
if (!firstRow) { console.log(' ⏭️ Skipped (no clickable rows)'); return; }
|
||||
await firstRow.click();
|
||||
await Promise.all([
|
||||
page.waitForResponse(resp => resp.url().includes('/packets/') && resp.status() === 200, { timeout: 15000 }),
|
||||
firstRow.click(),
|
||||
]);
|
||||
await page.waitForFunction(() => {
|
||||
const panel = document.getElementById('pktRight');
|
||||
return panel && !panel.classList.contains('empty');
|
||||
@@ -523,6 +544,11 @@ async function run() {
|
||||
});
|
||||
|
||||
await test('Compare page runs comparison', async () => {
|
||||
// Wait for dropdowns to be populated (may still be loading from previous test)
|
||||
await page.waitForFunction(() => {
|
||||
const selA = document.getElementById('compareObsA');
|
||||
return selA && selA.options.length > 2;
|
||||
}, { timeout: 10000 });
|
||||
const options = await page.$$eval('#compareObsA option', opts =>
|
||||
opts.filter(o => o.value).map(o => o.value)
|
||||
);
|
||||
@@ -544,6 +570,12 @@ async function run() {
|
||||
|
||||
// Test: Compare results show shared/unique breakdown (#129)
|
||||
await test('Compare results show shared/unique cards', async () => {
|
||||
// Wait for comparison results to fully render (depends on previous test)
|
||||
await page.waitForFunction(() => {
|
||||
return document.querySelector('.compare-card-both') &&
|
||||
document.querySelector('.compare-card-a') &&
|
||||
document.querySelector('.compare-card-b');
|
||||
}, { timeout: 10000 });
|
||||
// Results should be visible from previous test
|
||||
const cardBoth = await page.$('.compare-card-both');
|
||||
assert(cardBoth, 'Should have "shared" card (.compare-card-both)');
|
||||
@@ -566,6 +598,11 @@ async function run() {
|
||||
|
||||
// Test: Compare "both" tab shows table with shared packets
|
||||
await test('Compare both tab shows shared packets table', async () => {
|
||||
// Ensure compare results are present
|
||||
await page.waitForFunction(() => {
|
||||
const c = document.getElementById('compareContent');
|
||||
return c && c.textContent.trim().length > 20;
|
||||
}, { timeout: 10000 });
|
||||
const bothTab = await page.$('[data-cview="both"]');
|
||||
assert(bothTab, '"both" tab button not found');
|
||||
await bothTab.click();
|
||||
@@ -789,7 +826,11 @@ async function run() {
|
||||
// Check for summary stats
|
||||
const summary = await page.$('.obs-summary');
|
||||
assert(summary, 'Observer summary stats not found');
|
||||
// Verify table has rows
|
||||
// Wait for table rows to populate
|
||||
await page.waitForFunction(() => {
|
||||
const rows = document.querySelectorAll('#obsTable tbody tr');
|
||||
return rows.length > 0;
|
||||
}, { timeout: 10000 });
|
||||
const rows = await page.$$('#obsTable tbody tr');
|
||||
assert(rows.length > 0, `Expected >=1 observer rows, got ${rows.length}`);
|
||||
});
|
||||
|
||||
@@ -1322,7 +1322,7 @@ console.log('\n=== app.js: formatVersionBadge ===');
|
||||
assert.ok(result.includes('>v2.6.0</a>'), 'version text has v prefix');
|
||||
assert.ok(result.includes(`href="${GH}/commit/abc1234def5678"`), 'commit links to full hash');
|
||||
assert.ok(result.includes('>abc1234</a>'), 'commit display is truncated to 7');
|
||||
assert.ok(result.includes('[node]'), 'should show engine');
|
||||
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>node<'), 'should show engine name');
|
||||
});
|
||||
test('prod port 80: shows version', () => {
|
||||
const { formatVersionBadge } = makeBadgeSandbox('80');
|
||||
@@ -1348,7 +1348,7 @@ console.log('\n=== app.js: formatVersionBadge ===');
|
||||
assert.ok(!result.includes('v2.6.0'), 'staging should NOT show version');
|
||||
assert.ok(result.includes('>abc1234</a>'), 'should show commit hash');
|
||||
assert.ok(result.includes(`href="${GH}/commit/abc1234def5678"`), 'commit is linked');
|
||||
assert.ok(result.includes('[go]'), 'should show engine');
|
||||
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name');
|
||||
});
|
||||
test('staging port 81: hides version', () => {
|
||||
const { formatVersionBadge } = makeBadgeSandbox('81');
|
||||
@@ -1369,18 +1369,18 @@ console.log('\n=== app.js: formatVersionBadge ===');
|
||||
const result = formatVersionBadge('2.6.0', 'unknown', 'node');
|
||||
assert.ok(result.includes('>v2.6.0</a>'), 'should show version');
|
||||
assert.ok(!result.includes('unknown'), 'should not show unknown commit');
|
||||
assert.ok(result.includes('[node]'), 'should show engine');
|
||||
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>node<'), 'should show engine name');
|
||||
});
|
||||
test('skips commit when missing', () => {
|
||||
const { formatVersionBadge } = makeBadgeSandbox('');
|
||||
const result = formatVersionBadge('2.6.0', null, 'go');
|
||||
assert.ok(result.includes('>v2.6.0</a>'), 'should show version');
|
||||
assert.ok(result.includes('[go]'), 'should show engine');
|
||||
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name');
|
||||
});
|
||||
test('shows only engine when version/commit missing', () => {
|
||||
const { formatVersionBadge } = makeBadgeSandbox('3000');
|
||||
const result = formatVersionBadge(null, null, 'go');
|
||||
assert.ok(result.includes('[go]'), 'should show engine');
|
||||
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name');
|
||||
assert.ok(result.includes('version-badge'), 'should use version-badge class');
|
||||
});
|
||||
test('short commit not truncated in display', () => {
|
||||
@@ -1398,7 +1398,7 @@ console.log('\n=== app.js: formatVersionBadge ===');
|
||||
const { formatVersionBadge } = makeBadgeSandbox('8080');
|
||||
const result = formatVersionBadge('2.6.0', null, 'go');
|
||||
assert.ok(!result.includes('2.6.0'), 'no version on staging');
|
||||
assert.ok(result.includes('[go]'), 'engine shown');
|
||||
assert.ok(result.includes('engine-badge'), 'engine badge shown'); assert.ok(result.includes('>go<'), 'engine name shown');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
228
tools/seed-test-data.js
Normal file
228
tools/seed-test-data.js
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Seed synthetic test data into a running CoreScope server.
|
||||
* Usage: node tools/seed-test-data.js [baseUrl]
|
||||
* Default: http://localhost:13581
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const BASE = process.argv[2] || process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
const OBSERVERS = [
|
||||
{ id: 'E2E-SJC-1', iata: 'SJC' },
|
||||
{ id: 'E2E-SFO-2', iata: 'SFO' },
|
||||
{ id: 'E2E-OAK-3', iata: 'OAK' },
|
||||
];
|
||||
|
||||
const NODE_NAMES = [
|
||||
'TestNode Alpha', 'TestNode Beta', 'TestNode Gamma', 'TestNode Delta',
|
||||
'TestNode Epsilon', 'TestNode Zeta', 'TestNode Eta', 'TestNode Theta',
|
||||
];
|
||||
|
||||
function rand(a, b) { return Math.random() * (b - a) + a; }
|
||||
function randInt(a, b) { return Math.floor(rand(a, b + 1)); }
|
||||
function pick(a) { return a[randInt(0, a.length - 1)]; }
|
||||
function randomBytes(n) { return crypto.randomBytes(n); }
|
||||
function pubkeyFor(name) { return crypto.createHash('sha256').update(name).digest(); }
|
||||
|
||||
function encodeHeader(routeType, payloadType, ver = 0) {
|
||||
return (routeType & 0x03) | ((payloadType & 0x0F) << 2) | ((ver & 0x03) << 6);
|
||||
}
|
||||
|
||||
function buildPath(hopCount, hashSize = 2) {
|
||||
const pathByte = ((hashSize - 1) << 6) | (hopCount & 0x3F);
|
||||
const hops = crypto.randomBytes(hashSize * hopCount);
|
||||
return { pathByte, hops };
|
||||
}
|
||||
|
||||
function buildAdvert(name, role) {
|
||||
const pubKey = pubkeyFor(name);
|
||||
const ts = Buffer.alloc(4); ts.writeUInt32LE(Math.floor(Date.now() / 1000));
|
||||
const sig = randomBytes(64);
|
||||
let flags = 0x80 | 0x10; // hasName + hasLocation
|
||||
if (role === 'repeater') flags |= 0x02;
|
||||
else if (role === 'room') flags |= 0x04;
|
||||
else if (role === 'sensor') flags |= 0x08;
|
||||
else flags |= 0x01;
|
||||
const nameBuf = Buffer.from(name, 'utf8');
|
||||
const appdata = Buffer.alloc(9 + nameBuf.length);
|
||||
appdata[0] = flags;
|
||||
appdata.writeInt32LE(Math.round(37.34 * 1e6), 1);
|
||||
appdata.writeInt32LE(Math.round(-121.89 * 1e6), 5);
|
||||
nameBuf.copy(appdata, 9);
|
||||
const payload = Buffer.concat([pubKey, ts, sig, appdata]);
|
||||
const header = encodeHeader(1, 0x04, 0); // FLOOD + ADVERT
|
||||
const { pathByte, hops } = buildPath(randInt(0, 3));
|
||||
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
||||
}
|
||||
|
||||
function buildGrpTxt(channelHash = 0) {
|
||||
const mac = randomBytes(2);
|
||||
const enc = randomBytes(randInt(10, 40));
|
||||
const payload = Buffer.concat([Buffer.from([channelHash]), mac, enc]);
|
||||
const header = encodeHeader(1, 0x05, 0); // FLOOD + GRP_TXT
|
||||
const { pathByte, hops } = buildPath(randInt(0, 3));
|
||||
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a properly encrypted GRP_TXT packet that decrypts to a CHAN message.
|
||||
* Uses #LongFast channel key from channel-rainbow.json.
|
||||
*/
|
||||
function buildEncryptedGrpTxt(sender, message) {
|
||||
try {
|
||||
const CryptoJS = require('crypto-js');
|
||||
const { ChannelCrypto } = require('@michaelhart/meshcore-decoder/dist/crypto/channel-crypto');
|
||||
|
||||
const channelKey = '2cc3d22840e086105ad73443da2cacb8'; // #LongFast
|
||||
const text = `${sender}: ${message}`;
|
||||
const buf = Buffer.alloc(5 + text.length + 1);
|
||||
buf.writeUInt32LE(Math.floor(Date.now() / 1000), 0);
|
||||
buf[4] = 0;
|
||||
buf.write(text + '\0', 5, 'utf8');
|
||||
|
||||
const padded = Buffer.alloc(Math.ceil(buf.length / 16) * 16);
|
||||
buf.copy(padded);
|
||||
|
||||
const keyWords = CryptoJS.enc.Hex.parse(channelKey);
|
||||
const plaintextWords = CryptoJS.enc.Hex.parse(padded.toString('hex'));
|
||||
const encrypted = CryptoJS.AES.encrypt(plaintextWords, keyWords, {
|
||||
mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.NoPadding
|
||||
});
|
||||
const cipherHex = encrypted.ciphertext.toString(CryptoJS.enc.Hex);
|
||||
|
||||
const channelSecret = Buffer.alloc(32);
|
||||
Buffer.from(channelKey, 'hex').copy(channelSecret);
|
||||
const mac = CryptoJS.HmacSHA256(
|
||||
CryptoJS.enc.Hex.parse(cipherHex),
|
||||
CryptoJS.enc.Hex.parse(channelSecret.toString('hex'))
|
||||
);
|
||||
const macHex = mac.toString(CryptoJS.enc.Hex).substring(0, 4);
|
||||
|
||||
const chHash = ChannelCrypto.calculateChannelHash('#LongFast');
|
||||
const grpPayload = Buffer.from(
|
||||
chHash.toString(16).padStart(2, '0') + macHex + cipherHex, 'hex'
|
||||
);
|
||||
|
||||
const header = encodeHeader(1, 0x05, 0);
|
||||
const { pathByte, hops } = buildPath(randInt(0, 2));
|
||||
return Buffer.concat([Buffer.from([header, pathByte]), hops, grpPayload]);
|
||||
} catch (e) {
|
||||
// Fallback to unencrypted if crypto libs unavailable
|
||||
return buildGrpTxt(0);
|
||||
}
|
||||
}
|
||||
|
||||
function buildAck() {
|
||||
const payload = randomBytes(18);
|
||||
const header = encodeHeader(2, 0x03, 0);
|
||||
const { pathByte, hops } = buildPath(randInt(0, 2));
|
||||
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
||||
}
|
||||
|
||||
function buildTxtMsg() {
|
||||
const payload = Buffer.concat([randomBytes(6), randomBytes(6), randomBytes(4), randomBytes(20)]);
|
||||
const header = encodeHeader(2, 0x02, 0);
|
||||
const { pathByte, hops } = buildPath(randInt(0, 2));
|
||||
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
||||
}
|
||||
|
||||
function computeContentHash(hex) {
|
||||
return crypto.createHash('sha256').update(hex.toUpperCase()).digest('hex').substring(0, 16);
|
||||
}
|
||||
|
||||
async function post(path, body) {
|
||||
const r = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: r.status, data: await r.json() };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Seeding test data into ${BASE}...`);
|
||||
|
||||
const packets = [];
|
||||
|
||||
// 1. ADVERTs for each node (creates nodes with location for map)
|
||||
const roles = ['repeater', 'repeater', 'room', 'companion', 'repeater', 'companion', 'sensor', 'repeater'];
|
||||
for (let i = 0; i < NODE_NAMES.length; i++) {
|
||||
const obs = pick(OBSERVERS);
|
||||
const hex = buildAdvert(NODE_NAMES[i], roles[i]).toString('hex').toUpperCase();
|
||||
const hash = computeContentHash(hex);
|
||||
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: 5.0, rssi: -80 });
|
||||
// Send same advert from multiple observers for compare page
|
||||
for (const otherObs of OBSERVERS) {
|
||||
if (otherObs.id !== obs.id) {
|
||||
packets.push({ hex, observer: otherObs.id, region: otherObs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Encrypted GRP_TXT packets (creates channel messages for channels page)
|
||||
const chatMessages = [
|
||||
['Alice', 'Hello everyone!'], ['Bob', 'Hey Alice!'], ['Charlie', 'Good morning'],
|
||||
['Alice', 'How is the mesh today?'], ['Bob', 'Looking great, 8 nodes online'],
|
||||
['Charlie', 'I just set up a new repeater'], ['Alice', 'Nice! Where is it?'],
|
||||
['Bob', 'Signal looks strong from here'], ['Charlie', 'On top of the hill'],
|
||||
['Alice', 'Perfect location!'],
|
||||
];
|
||||
for (const [sender, message] of chatMessages) {
|
||||
const obs = pick(OBSERVERS);
|
||||
const hex = buildEncryptedGrpTxt(sender, message).toString('hex').toUpperCase();
|
||||
const hash = computeContentHash(hex);
|
||||
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||
}
|
||||
|
||||
// 3. Unencrypted GRP_TXT packets (won't create channel entries but add packet variety)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const obs = pick(OBSERVERS);
|
||||
const hex = buildGrpTxt(randInt(0, 3)).toString('hex').toUpperCase();
|
||||
const hash = computeContentHash(hex);
|
||||
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||
}
|
||||
|
||||
// 3. ACK packets
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const obs = pick(OBSERVERS);
|
||||
const hex = buildAck().toString('hex').toUpperCase();
|
||||
const hash = computeContentHash(hex);
|
||||
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||
}
|
||||
|
||||
// 4. TXT_MSG packets
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const obs = pick(OBSERVERS);
|
||||
const hex = buildTxtMsg().toString('hex').toUpperCase();
|
||||
const hash = computeContentHash(hex);
|
||||
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||
}
|
||||
|
||||
// 5. Extra packets with shared hashes (for trace/compare)
|
||||
for (let t = 0; t < 5; t++) {
|
||||
const hex = buildGrpTxt(0).toString('hex').toUpperCase();
|
||||
const traceHash = computeContentHash(hex);
|
||||
for (const obs of OBSERVERS) {
|
||||
packets.push({ hex, observer: obs.id, region: obs.iata, hash: traceHash, snr: 5, rssi: -80 });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Injecting ${packets.length} packets...`);
|
||||
let ok = 0, fail = 0;
|
||||
for (const pkt of packets) {
|
||||
const r = await post('/api/packets', pkt);
|
||||
if (r.status === 200) ok++;
|
||||
else { fail++; if (fail <= 3) console.error(' Inject fail:', r.data); }
|
||||
}
|
||||
console.log(`Done: ${ok} ok, ${fail} fail`);
|
||||
|
||||
if (fail > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
Reference in New Issue
Block a user