Merge simtrace-analyser PWA into frontend/ (monorepo); serve it same-origin

- frontend/: simtrace-analyser PWA (index.html, sw.js, manifest.json,
  icons, tests/index.test.html) copied in fresh
- server.py: static file serving (_STATIC_MIME, _serve_static) with
  path-traversal guard; injects window.SIMTRACE_EMBEDDED='1' into
  index.html so the PWA defaults to a relative API base
- server __main__: --web-dir (default <repo>/frontend)
- frontend: embedded same-origin mode; sw.js relative precache paths +
  cache v4; GitHub link + version-warn point at simtrace2-pysniff
- version 1.1.0 everywhere (__version__, pyproject, PWA_VERSION,
  manifest); MIN_SERVER_VERSION stays 1.0.0
- README/scripts/.gitignore updated

Tests: 107.
This commit is contained in:
catarrh committed 2026-08-14 16:26:28 +03:00
1 parent 8b7b10b8fa
commit b900a8a3e1
16 files changed
+1707 -18

No files matched your search

+1
View File
@@ -7,3 +7,4 @@ build/
.venv/
decode-errors.log
auth-test.log
.DS_Store
+27 -12
View File
@@ -1,7 +1,9 @@
# simtrace2-pysniff
Python-based replacement for `simtrace2-sniff` — SIM card communication sniffer
for Osmocom SIMtrace2 hardware (firmware in **trace** mode).
for Osmocom SIMtrace2 hardware (firmware in **trace** mode). It bundles the
**simtrace-analyser** PWA (in [`frontend/`](frontend/)) and an analysis server
that both captures APDU traffic and serves that PWA.
**Single dependency**: PyUSB (libusb wrapper). No libosmocore, no libosmosim.
@@ -59,11 +61,11 @@ OPTIONS can also be set via environment variables:
FORMAT=timestamp GSMTAP=127.0.0.1 ./sniff-start.sh
```
### Analysis server (`simtrace2-pysniff-server`)
### Analysis server + PWA (`simtrace2-pysniff-server`)
Stores captured APDU traffic in SQLite and serves it over HTTP for the
[simtrace-analyser](https://github.com/anttro/simtrace-analyser) PWA.
Two capture modes:
Stores captured APDU traffic in SQLite and serves it over HTTP, together with
the bundled **simtrace-analyser** PWA (`frontend/`) on the same origin. Two
capture modes:
```sh
# Listen for GSMTAP from simtrace2-pysniff (or original simtrace2-sniff):
@@ -73,9 +75,24 @@ simtrace2-pysniff-server --capture gsmtap
simtrace2-pysniff-server --capture direct
```
The PWA then connects to `http://127.0.0.1:8081` for live viewing and
session management. See the [simtrace-analyser](https://github.com/anttro/simtrace-analyser)
repo for the browser interface.
Then open **http://127.0.0.1:8081/** in a browser — the PWA is served by the
server, so the UI and the API share an origin and no CORS/Private-Network-Access
setup is involved. Use `--web-dir PATH` to serve a different PWA directory
(default: `<repo>/frontend`).
### Hosted PWA (landing page)
A standalone copy of the PWA is hosted at **https://simtrace.atroshin.ru**.
It is a pure frontend: point it (Settings → Server URL) at a locally running
`simtrace2-pysniff-server`.
> **Browser restriction:** when the PWA is served from a public HTTPS host,
> reaching a local server (`http://127.0.0.1:8081`) requires two things: the
> server must send `Access-Control-Allow-Private-Network: true` (this server
> does), and the browser must be allowed to access the local network — in
> Chrome/Edge/Vivaldi: Site settings → Local network access → allow the site
> (or accept the permission prompt). Without the browser permission, the
> request to `127.0.0.1` is blocked before any preflight is sent.
### Python module (cross-platform)
@@ -183,7 +200,5 @@ Use `--inactivity-timeout` to also trigger reconnect on silent firmware hangs.
## Related
- **[simtrace-analyser](https://github.com/anttro/simtrace-analyser)** — a
browser-based PWA for viewing live captures and browsing/exporting saved
sessions. Connects to `simtrace2-pysniff-server`.
Try it now: **https://simtrace.atroshin.ru**
- **[simtrace2](https://gitea.osmocom.org/sim-card/simtrace2)** — the upstream
SIMtrace2 hardware/firmware project that this tool sniffs from.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+1451
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "SIMtrace Analyser",
"short_name": "SIMtrace",
"description": "PWA frontend for browsing SIMtrace2 APDU traces",
"version": "1.1.0",
"start_url": "index.html",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
shape-rendering="geometricPrecision"
text-rendering="geometricPrecision"
image-rendering="optimizeQuality"
fill-rule="evenodd"
clip-rule="evenodd"
viewBox="0 0 507 511.73"
version="1.1"
id="svg1"
sodipodi:docname="sim-analyser.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#3b82f6"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.0446382"
inkscape:cx="206.29151"
inkscape:cy="241.23184"
inkscape:window-width="1707"
inkscape:window-height="1007"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
d="M224.639 0c59.668 0 116.663 23.625 158.855 65.816 58.277 58.264 79.952 144.187 56.478 223.076-5.44 18.308-13.169 35.634-22.82 51.652l87.013 94.825c4.008 4.376 3.736 11.192-.627 15.2l-63.894 58.345c-4.362 4.008-11.15 3.694-15.145-.668l-83.237-91.553c-35.253 21.471-75.345 32.676-116.623 32.676-59.681 0-116.678-23.624-158.869-65.816a224.953 224.953 0 01-48.762-72.959C-17.808 226.73 1.985 129.628 65.798 65.843 107.907 23.611 165.012 0 224.639 0zM351.39 97.92C186.06-67.451-70.51 183.202 97.833 351.436c51.257 51.134 128.646 66.579 195.362 38.974 67.083-27.864 110.692-93.107 110.692-165.739 0-24.32-4.812-47.508-13.523-68.542A180.032 180.032 0 00351.39 97.92z"
fill="#3b82f6" id="path1" />
<path
style="fill:#3b82f6;stroke-width:4.8596"
d="m 128.04356,356.87391 c -13.07564,-2.62909 -24.733,-12.68507 -29.511929,-25.45795 -2.596909,-6.94081 -2.6389,-8.86274 -2.29299,-104.97903 l 0.35231,-97.90875 3.788399,-6.8388 c 4.61261,-8.3267 10.50929,-13.68042 19.93961,-18.10361 l 7.13036,-3.34441 h 97.07534 97.0753 l 7.13037,3.34441 c 9.43036,4.42319 15.32699,9.77691 19.93961,18.10361 l 3.78839,6.8388 0.35231,97.90875 c 0.34602,96.11629 0.30373,98.03822 -2.29294,104.97903 -3.50512,9.36825 -10.87912,17.4984 -20.10814,22.17001 l -7.52385,3.80852 -95.14666,0.19674 c -52.33067,0.10843 -97.19362,-0.2148 -99.69555,-0.71758 z m 46.33625,-48.98143 v -28.28685 h -28.28682 -28.28687 v 22.40224 22.40224 l 3.47213,4.12639 c 5.66491,6.73236 9.07721,7.53679 32.20788,7.59246 l 20.89368,0.0505 z m 78.43168,0.0486 v -28.23905 l -28.28799,-0.36934 -28.288,-0.36933 0.001,28.60829 0.001,28.60828 h 28.28682 28.28683 z m 67.88799,26.4325 c 1.90962,-0.99356 4.91852,-3.35297 6.68645,-5.24315 3.14502,-3.3625 3.22311,-3.93424 3.61699,-26.48038 l 0.40286,-23.04367 -24.5107,-0.16089 c -13.48091,-0.0884 -25.3786,-0.16089 -26.43935,-0.16089 -6.0649,0 -5.78596,-1.37936 -5.78596,28.60828 v 28.28683 h 21.27901 c 15.77042,0 22.17779,-0.46749 24.75093,-1.80651 z M 331.24322,228.81866 V 199.88895 H 255.9675 180.69175 l -3.15598,-3.15595 -3.15596,-3.15602 v -36.05986 -36.05988 h -20.61354 c -22.40511,0 -26.44906,0.88289 -32.10286,7.0087 l -3.21436,3.4828 -0.35671,62.89983 -0.3567,62.89984 h 106.7539 106.75388 z m -78.43173,-78.43245 v -28.92976 h -28.28683 -28.28682 v 28.07258 c 0,15.43993 0.38586,28.45827 0.85719,28.92974 0.47138,0.4714 13.20053,0.85713 28.28682,0.85713 h 27.42964 z m 78.43173,6.61896 c 0,-21.05484 -0.16425,-22.55671 -2.91862,-26.67962 -5.20034,-7.78442 -9.192,-8.86481 -32.76137,-8.86705 l -20.89371,-0.002 v 28.07259 c 0,15.43993 0.38585,28.45827 0.85718,28.92974 0.47138,0.47139 13.20053,0.85712 28.28683,0.85712 h 27.42969 z"
id="path1-5"
sodipodi:nodetypes="csscsscccsscccsccccccccccscccccccccccccscccsscsccccccccccssccccccccccscsccscccscscs" />
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+45
View File
@@ -0,0 +1,45 @@
const CACHE = 'simtrace-analyser-v4';
const PRECACHE = [
'index.html',
'manifest.json',
'icon-192.png',
'icon-512.png',
'favicon.png',
'sim-analyser.svg',
];
self.addEventListener('install', e => {
self.skipWaiting();
e.waitUntil(
caches.open(CACHE)
.then(cache => cache.addAll(PRECACHE))
.then(() => console.log('SW: precache done'))
.catch(err => console.error('SW: precache failed', err))
);
});
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys()
.then(keys => Promise.all(
keys.filter(k => k !== CACHE).map(k => caches.delete(k))
))
.then(() => self.clients.claim())
.then(() => console.log('SW: activated, controlling clients'))
);
});
self.addEventListener('fetch', e => {
// Network-first: the local server is always up, so always serve fresh
// content and fall back to cache only when offline.
e.respondWith(
fetch(e.request)
.then(r => {
const clone = r.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
return r;
})
.catch(() => caches.match(e.request))
);
});
+59
View File
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<meta charset="UTF-8">
<title>Tests — SIMtrace Analyser</title>
<body>
<h1>HTML Validator Test</h1>
<p>Page loads cleanly with no JS errors — check browser console.</p>
<script>
const tests = [];
// Test INS name mapping
function testInsNames() {
const n = (h) => ({'a4':'SELECT','b0':'READ BINARY','c2':'ENVELOPE','14':'TERMINAL RESPONSE',
'88':'AUTHENTICATE','f2':'STATUS','12':'FETCH','20':'VERIFY'}[h] || '?' + h);
console.assert(n('a4') === 'SELECT', 'INS a4 -> SELECT');
console.assert(n('c2') === 'ENVELOPE', 'INS c2 -> ENVELOPE');
console.assert(n('14') === 'TERMINAL RESPONSE', 'INS 14 -> TERMINAL RESPONSE');
console.assert(n('ff') !== '?', 'Unknown INS returns placeholder');
tests.push('testInsNames: OK');
}
// Test elapsed formatting
function testFmtElapsed() {
const fmt = (e) => {
const s = Math.floor(e);
const ms = Math.round((e - s) * 1000);
return String(s).padStart(4, '0') + '.' + String(ms).padStart(3, '0');
};
console.assert(fmt(0) === '0000.000', '0 -> 0000.000');
console.assert(fmt(1.5) === '0001.500', '1.5 -> 0001.500');
console.assert(fmt(12.034) === '0012.034', '12.034 -> 0012.034');
tests.push('testFmtElapsed: OK');
}
// Test data formatter
function testFmtData() {
const fmt = (hex, limit) => {
const pairs = (hex.match(/.{1,2}/g) || []);
const shown = pairs.slice(0, limit || 32);
let s = shown.join(' ');
if (pairs.length > (limit || 32)) s += ' ...';
return s;
};
const r = fmt('a0a40000023f00', 16);
console.assert(r === 'a0 a4 00 00 02 3f 00', 'Short data');
const r2 = fmt('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff', 4);
console.assert(r2.startsWith('00 11 22 33 ...'), 'Long data truncated');
tests.push('testFmtData: OK');
}
testInsNames();
testFmtElapsed();
testFmtData();
document.body.innerHTML += `<pre>${tests.join('\n')}</pre>
<p style="color:green; font-weight:bold;">All ${tests.length} tests passed.</p>`;
</script>
</body>
</html>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "simtrace2-pysniff"
version = "1.0.0"
version = "1.1.0"
description = "Python-based sniffing tool for SIMtrace2 hardware (SIM card communication sniffer)"
readme = {file = "README.md", content-type = "text/markdown"}
license = {text = "GPL-2.0-or-later"}
+1
View File
@@ -72,4 +72,5 @@ ARGS+=(--port "$PORT")
ARGS+=("${EXTRA_ARGS[@]}")
echo "Starting simtrace2-pysniff-server..." >&2
echo "PWA (simtrace-analyser) will be served at http://127.0.0.1:$PORT/" >&2
exec env PYTHONPATH="$PROJECT_DIR" python3 -m simtrace2_pysniff.server "${ARGS[@]}"
+1 -1
View File
@@ -6,7 +6,7 @@ from .gsmtap import GsmtapSender, GsmtapReceiver
from .pcap import PcapWriter
from .dump import FileDumper, format_message
__version__ = '1.0.0'
__version__ = '1.1.0'
__all__ = [
'SniffSession',
'DeviceDisconnected',
+12 -1
View File
@@ -6,6 +6,7 @@ Usage:
"""
import argparse
import os
import sys
from http.server import HTTPServer
@@ -14,10 +15,16 @@ from .database import Database, DEFAULT_DB_PATH
from .capture import CaptureManager, GsmtapListener, DirectSniffer
def _default_web_dir():
# <repo>/frontend, whether run from source or an editable install.
# __file__ is <repo>/simtrace2_pysniff/server/__main__.py → up three levels.
return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'frontend')
def main():
p = argparse.ArgumentParser(
prog='simtrace2-pysniff-server',
description='HTTP API server for SIMtrace2 APDU capture and analysis')
description='HTTP API + PWA server for SIMtrace2 APDU capture and analysis')
p.add_argument('--port', type=int, default=8081,
help='HTTP server port (default: 8081)')
p.add_argument('--db', default=DEFAULT_DB_PATH,
@@ -26,6 +33,8 @@ def main():
help='Capture mode: gsmtap (listen UDP 4729) or direct (SIMtrace2 USB)')
p.add_argument('--gsmtap-port', type=int, default=4729,
help='UDP port for GSMTAP listener (default: 4729)')
p.add_argument('--web-dir', default=_default_web_dir(), metavar='PATH',
help='Directory with the simtrace-analyser PWA static files to serve (default: <repo>/frontend)')
args = p.parse_args()
db = Database(args.db)
@@ -41,12 +50,14 @@ def main():
RequestHandler.capture = capture
server = HTTPServer(('127.0.0.1', args.port), RequestHandler)
server.web_dir = args.web_dir
print(f'simtrace2-pysniff-server — http://127.0.0.1:{args.port}', file=sys.stderr)
print(f' capture mode: {args.capture}', file=sys.stderr)
if args.capture == 'gsmtap':
print(f' GSMTAP port: {args.gsmtap_port}', file=sys.stderr)
print(f' database: {args.db}', file=sys.stderr)
print(f' PWA served at http://127.0.0.1:{args.port}/', file=sys.stderr)
print(file=sys.stderr)
try:
+49 -2
View File
@@ -1,4 +1,4 @@
"""HTTP API server for simtrace-analyser PWA."""
"""HTTP API + static PWA server for simtrace2-pysniff."""
import json
import os
@@ -16,6 +16,23 @@ from ..pcap import build_pcap, parse_pcap, parse_pcapng
from .. import __version__
# Static file serving (the PWA lives in <repo>/frontend, served by this server
# so the UI and the API share an origin and no CORS/PNA is involved).
_STATIC_MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.webmanifest': 'application/manifest+json',
'.map': 'application/json',
'.wasm': 'application/wasm',
}
_EMBEDDED_MARKER = "<script>window.SIMTRACE_EMBEDDED='1'</script>"
def _content_disposition(filename):
"""RFC 6266/5987 Content-Disposition filename (ASCII fallback + UTF-8)."""
fallback = filename.encode('ascii', 'replace').decode('ascii').replace('"', '_')
@@ -71,6 +88,36 @@ class RequestHandler(BaseHTTPRequestHandler):
path = parsed.path.rstrip('/')
return path, parse_qs(parsed.query)
def _serve_static(self):
web_dir = getattr(self.server, 'web_dir', None)
if not web_dir:
self._send_error(404, 'Not found')
return
rel = self.path.split('?', 1)[0].lstrip('/')
if rel in ('', '/'):
rel = 'index.html'
if '..' in rel.split('/') or rel.startswith('/'):
self._send_error(404, 'Not found')
return
fs_path = os.path.join(web_dir, rel)
if not os.path.isfile(fs_path):
self._send_error(404, 'Not found')
return
with open(fs_path, 'rb') as f:
data = f.read()
if rel == 'index.html':
# Tell the frontend it is served by this server (same origin), so
# it can default to a relative API base instead of 127.0.0.1:8081.
data = data.replace(b'</head>', _EMBEDDED_MARKER.encode() + b'</head>', 1)
content_type = _STATIC_MIME.get(os.path.splitext(rel)[1].lower(), 'application/octet-stream')
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(data)))
if rel in ('index.html', 'sw.js'):
self.send_header('Cache-Control', 'no-cache')
self.end_headers()
self.wfile.write(data)
# --- CORS preflight ---
def do_OPTIONS(self):
self.send_response(204)
@@ -110,7 +157,7 @@ class RequestHandler(BaseHTTPRequestHandler):
msg_id = int(path.split('/')[-1])
self._handle_get_apdu(msg_id)
else:
self._send_error(404, 'Not found')
self._serve_static()
# --- POST ---
def do_POST(self):
+1 -1
View File
@@ -140,7 +140,7 @@ class TestImportEndpoint(unittest.TestCase):
class TestVersion(unittest.TestCase):
def test_package_version(self):
from simtrace2_pysniff import __version__
self.assertEqual(__version__, '1.0.0')
self.assertEqual(__version__, '1.1.0')
def test_status_includes_version(self):
import os