diff --git a/.gitignore b/.gitignore index d720fdd..bd3d227 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ build/ .venv/ decode-errors.log auth-test.log +.DS_Store diff --git a/README.md b/README.md index a52f0b8..485e224 100644 --- a/README.md +++ b/README.md @@ -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: `/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. diff --git a/frontend/favicon.png b/frontend/favicon.png new file mode 100644 index 0000000..dd5bfd0 Binary files /dev/null and b/frontend/favicon.png differ diff --git a/frontend/icon-192.png b/frontend/icon-192.png new file mode 100644 index 0000000..fcd79fb Binary files /dev/null and b/frontend/icon-192.png differ diff --git a/frontend/icon-512.png b/frontend/icon-512.png new file mode 100644 index 0000000..f37ebb7 Binary files /dev/null and b/frontend/icon-512.png differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a9ea738 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,1451 @@ + + + + + +SIMtrace Analyser + + + + + + + + +
+
+
Trace
+
Sessions
+
Settings
+
+
SIM-ME GSMTAP trace analyser
+
+ + GitHub + + +
+
+
+
+ + + + +
+
+
+
+
+
Click an APDU to see details
+
+
+
+

Command Stats

+
—
+
+
+
+ Not connected + 0 messages +
+
+ + +
+
+ + + + + +
+
+
+

No saved sessions

+

Start a capture from the Trace tab to create sessions.

+
+
+
+ + +
+
+ +
+ + +
+
+ ⬤ Not connected — ensure the server is running on the URL above +
+
+
+
+
+
+ + + + + + + diff --git a/frontend/manifest.json b/frontend/manifest.json new file mode 100644 index 0000000..fbc3875 --- /dev/null +++ b/frontend/manifest.json @@ -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" } + ] +} diff --git a/frontend/sim-analyser.svg b/frontend/sim-analyser.svg new file mode 100644 index 0000000..70c99c5 --- /dev/null +++ b/frontend/sim-analyser.svg @@ -0,0 +1,45 @@ + + + + + + + diff --git a/frontend/sw.js b/frontend/sw.js new file mode 100644 index 0000000..2524dd8 --- /dev/null +++ b/frontend/sw.js @@ -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)) + ); +}); diff --git a/frontend/tests/index.test.html b/frontend/tests/index.test.html new file mode 100644 index 0000000..e8b306e --- /dev/null +++ b/frontend/tests/index.test.html @@ -0,0 +1,59 @@ + + + +Tests — SIMtrace Analyser + +

HTML Validator Test

+

Page loads cleanly with no JS errors — check browser console.

+ + + diff --git a/pyproject.toml b/pyproject.toml index a954736..38e041f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/server-start.sh b/server-start.sh index 04c316a..7460b82 100755 --- a/server-start.sh +++ b/server-start.sh @@ -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[@]}" diff --git a/simtrace2_pysniff/__init__.py b/simtrace2_pysniff/__init__.py index c8e52d8..82cb9e9 100644 --- a/simtrace2_pysniff/__init__.py +++ b/simtrace2_pysniff/__init__.py @@ -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', diff --git a/simtrace2_pysniff/server/__main__.py b/simtrace2_pysniff/server/__main__.py index 7736de8..e52ced0 100644 --- a/simtrace2_pysniff/server/__main__.py +++ b/simtrace2_pysniff/server/__main__.py @@ -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(): + # /frontend, whether run from source or an editable install. + # __file__ is /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: /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: diff --git a/simtrace2_pysniff/server/server.py b/simtrace2_pysniff/server/server.py index ddb834f..bfddafa 100644 --- a/simtrace2_pysniff/server/server.py +++ b/simtrace2_pysniff/server/server.py @@ -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 /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 = "" + + 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'', _EMBEDDED_MARKER.encode() + b'', 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): diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index ced4e3a..dc3e09f 100644 --- a/tests/test_pcap_import.py +++ b/tests/test_pcap_import.py @@ -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