diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a6b3ec..62224e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,32 @@ The release notes on GitHub are taken from this file: the section whose heading is the version number. +## 1.5.2 + +Fixes +- Client and server were swapped for half of many TCP connections: exported + records (NetFlow, IPFIX, local capture, pcap files) carry the flags of all + their packets together, so a client's SYN followed by ACKs looked like a + server's SYN+ACK and its upload was counted the wrong way round. The flags + now decide only for single packets (sFlow samples, lone SYNs); local + capture and pcap files use the first packet of each connection; other + records go by the ports. Data stored before stays as it is; pcap files are + right once imported again. + +Offline pcap analysis +- One file at a time, each in a database of its own: **Analyse** on a + file's row shows that file on every page; the bar at the top switches to + another. Deleting a file no longer re-imports the others. + +Interfaces +- Interface check explains that local capture has nothing to compare (no + device interfaces or counters), and the interface picker is hidden when + there are no interfaces. + +Docs +- README: the Windows commands for local capture (`traffic66.exe interfaces`, + `traffic66.exe -capture Wi-Fi`). + ## 1.5.1 - The web UI's scripts and styles are no longer kept by the browser across diff --git a/README.md b/README.md index 320e04b..3ba9ce1 100644 --- a/README.md +++ b/README.md @@ -324,8 +324,10 @@ sampling. **Offline pcap analysis** shows packet captures (pcap, pcapng) with the same pages, apart from the live data: `traffic66 a.pcap b.pcapng` starts on 127.0.0.1 and opens the browser (up to 3 files, 3 GB; Ctrl+C deletes the -imported data), or upload up to 3 files of 50 MB on that page. It works on -flows, not packet contents. +imported data), or upload up to 3 files of 50 MB on that page. One file is +analysed at a time, each in a database of its own: **Analyse** on a file's +row shows it on every page, and the bar at the top switches to another. It +works on flows, not packet contents.  @@ -340,9 +342,17 @@ in a browser, q quit; `-lang` picks the language. **Local capture** builds flows from a local interface, best a port connected to a switch's mirror port: `traffic66 interfaces` lists them, -`-capture eth1` (or a Windows name or number) captures. Linux needs root or -`setcap cap_net_raw,cap_net_admin+ep`, macOS root, Windows -[Npcap](https://npcap.com). Captured flows come from the device `127.0.0.1`. +`-capture eth1` captures. On Windows: + +``` +traffic66.exe interfaces # list the network cards: name, number, address +traffic66.exe -capture Wi-Fi # capture on the wireless card (or by number: -capture 2) +``` + +Linux needs root or `setcap cap_net_raw,cap_net_admin+ep`, macOS root, +Windows [Npcap](https://npcap.com). Captured flows come from the device +`127.0.0.1`. Local capture has no device interfaces or counters, so +**Interface check** has nothing to compare for it. ## 10. Options and data diff --git a/docs/README.ar.md b/docs/README.ar.md index 816259a..63aaed9 100644 --- a/docs/README.ar.md +++ b/docs/README.ar.md @@ -343,7 +343,9 @@ NetFlow/IPFIX بقدر تأخر الأجهزة في التصدير (دقيقتا يعرض **تحليل pcap دون اتصال** ملفات التقاط الحزم (pcap وpcapng) بالصفحات نفسها، بمعزل عن البيانات الحية: يبدأ `traffic66 a.pcap b.pcapng` على 127.0.0.1 ويفتح المتصفح (حتى 3 ملفات، 3 GB؛ يحذف Ctrl+C البيانات المستوردة)، أو ارفع حتى 3 -ملفات بحجم 50 MB في تلك الصفحة. يعمل على التدفقات، لا على محتوى الحزم. +ملفات بحجم 50 MB في تلك الصفحة. يُحلَّل ملف واحد في كل مرة، ولكل ملف قاعدة +بيانات خاصة به: يعرض **تحليل** في صف الملف بياناته في كل الصفحات، ويبدّل +الشريط في الأعلى إلى ملف آخر. يعمل على التدفقات، لا على محتوى الحزم.  @@ -357,10 +359,18 @@ NetFlow/IPFIX بقدر تأخر الأجهزة في التصدير (دقيقتا  **الالتقاط المحلي** يبني التدفقات من واجهة محلية، ويُفضَّل منفذ موصول بمنفذ -المرآة في مبدّل: يسردها `traffic66 interfaces`، ويلتقط `-capture eth1` (أو اسم -أو رقم في Windows). يحتاج Linux إلى root أو -`setcap cap_net_raw,cap_net_admin+ep`، وmacOS إلى root، وWindows إلى -[Npcap](https://npcap.com). تأتي التدفقات الملتقطة من الجهاز `127.0.0.1`. +المرآة في مبدّل: يسردها `traffic66 interfaces`، ويلتقط `-capture eth1`. على +Windows: + +``` +traffic66.exe interfaces # list the network cards: name, number, address +traffic66.exe -capture Wi-Fi # capture on the wireless card (or by number: -capture 2) +``` + +يحتاج Linux إلى root أو `setcap cap_net_raw,cap_net_admin+ep`، وmacOS إلى +root، وWindows إلى [Npcap](https://npcap.com). تأتي التدفقات الملتقطة من +الجهاز `127.0.0.1`. لا واجهات ولا عدّادات جهاز للالتقاط المحلي، لذا ليس لدى +**مطابقة الواجهات** ما تقارنه فيه. diff --git a/docs/README.bn.md b/docs/README.bn.md index 1290751..148b2e7 100644 --- a/docs/README.bn.md +++ b/docs/README.bn.md @@ -351,8 +351,10 @@ export করে ততটা (সর্বোচ্চ 2 মিনিট)। 6 **অফলাইন pcap বিশ্লেষণ** প্যাকেট ক্যাপচার (pcap, pcapng) একই পেজে দেখায়, লাইভ ডেটা থেকে আলাদা রেখে: `traffic66 a.pcap b.pcapng` 127.0.0.1-এ চালু হয় এবং browser খোলে (সর্বোচ্চ 3টি ফাইল, 3 GB; Ctrl+C ইমপোর্ট করা ডেটা মুছে দেয়), -অথবা ওই পেজে 50 MB পর্যন্ত 3টি ফাইল আপলোড করুন। এটি flow নিয়ে কাজ করে, -প্যাকেটের বিষয়বস্তু নিয়ে নয়। +অথবা ওই পেজে 50 MB পর্যন্ত 3টি ফাইল আপলোড করুন। একবারে একটি ফাইল বিশ্লেষণ +করা হয়, প্রতিটি নিজস্ব আলাদা ডেটাবেসে: কোনো ফাইলের সারিতে **বিশ্লেষণ** +সেটিকে প্রতিটি পেজে দেখায়, আর ওপরের বার অন্য ফাইলে বদলে দেয়। এটি flow নিয়ে +কাজ করে, প্যাকেটের বিষয়বস্তু নিয়ে নয়।  @@ -367,9 +369,17 @@ browser-এ খুলুন, q বেরিয়ে যান; `-lang` ভা **Local capture** একটি local interface থেকে flow তৈরি করে, সবচেয়ে ভালো হয় switch-এর mirror port-এ যুক্ত একটি port: `traffic66 interfaces` সেগুলোর তালিকা -দেয়, `-capture eth1` (অথবা Windows-এর নাম বা নম্বর) capture করে। Linux-এ root -বা `setcap cap_net_raw,cap_net_admin+ep` লাগে, macOS-এ root, Windows-এ -[Npcap](https://npcap.com)। capture করা flow ডিভাইস `127.0.0.1` থেকে আসে। +দেয়, `-capture eth1` capture করে। Windows-এ: + +``` +traffic66.exe interfaces # list the network cards: name, number, address +traffic66.exe -capture Wi-Fi # capture on the wireless card (or by number: -capture 2) +``` + +Linux-এ root বা `setcap cap_net_raw,cap_net_admin+ep` লাগে, macOS-এ root, +Windows-এ [Npcap](https://npcap.com)। capture করা flow ডিভাইস `127.0.0.1` +থেকে আসে। Local capture-এ ডিভাইসের interface বা counter নেই, তাই +**ইন্টারফেস মিলানো**-তে এর জন্য তুলনা করার কিছু নেই। diff --git a/docs/README.es.md b/docs/README.es.md index ca00f54..be1e236 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -360,7 +360,10 @@ los escaneos muy pequeños pueden ocultarse tras el muestreo. las mismas páginas, aparte de los datos en vivo: `traffic66 a.pcap b.pcapng` arranca en 127.0.0.1 y abre el navegador (hasta 3 archivos, 3 GB; Ctrl+C borra los datos importados), o suba en esa página hasta 3 archivos de 50 MB. -Trabaja con flujos, no con el contenido de los paquetes. +Se analiza un archivo a la vez, cada uno en su propia base de datos: +**Analizar** en la fila de un archivo lo muestra en todas las páginas, y la +barra superior cambia a otro. Trabaja con flujos, no con el contenido de los +paquetes.  @@ -375,10 +378,18 @@ tiempo, w abrir en un navegador, q salir; `-lang` elige el idioma. **Captura local** construye flujos desde una interfaz local, idealmente un puerto conectado al puerto espejo de un switch: `traffic66 interfaces` las -lista y `-capture eth1` (o un nombre o número de Windows) captura. Linux -necesita root o `setcap cap_net_raw,cap_net_admin+ep`, macOS root, Windows -[Npcap](https://npcap.com). Los flujos capturados provienen del equipo -`127.0.0.1`. +lista y `-capture eth1` captura. En Windows: + +``` +traffic66.exe interfaces # list the network cards: name, number, address +traffic66.exe -capture Wi-Fi # capture on the wireless card (or by number: -capture 2) +``` + +Linux necesita root o `setcap cap_net_raw,cap_net_admin+ep`, macOS root, +Windows [Npcap](https://npcap.com). Los flujos capturados provienen del +equipo `127.0.0.1`. La captura local no tiene interfaces ni contadores del +equipo, así que **Verificación de interfaces** no tiene nada que comparar +para ella. diff --git a/docs/README.fr.md b/docs/README.fr.md index f5a25ac..b59bcdd 100644 --- a/docs/README.fr.md +++ b/docs/README.fr.md @@ -369,8 +369,11 @@ l'échantillonnage. pcapng) avec les mêmes pages, à part des données en direct : `traffic66 a.pcap b.pcapng` démarre sur 127.0.0.1 et ouvre le navigateur (jusqu'à 3 fichiers, 3 Go ; Ctrl+C supprime les données importées), ou -importez sur cette page jusqu'à 3 fichiers de 50 Mo. L'analyse porte sur -les flux, pas sur le contenu des paquets. +importez sur cette page jusqu'à 3 fichiers de 50 Mo. Un seul fichier est +analysé à la fois, chacun dans sa propre base de données : **Analyser** sur +la ligne d'un fichier l'affiche sur toutes les pages, et la barre du haut +passe à un autre. L'analyse porte sur les flux, pas sur le contenu des +paquets.  @@ -386,10 +389,18 @@ la langue. **Capture locale** construit des flux à partir d'une interface locale, idéalement un port relié au port miroir d'un switch : `traffic66 interfaces` -les liste, `-capture eth1` (ou un nom ou numéro Windows) capture. Linux -demande root ou `setcap cap_net_raw,cap_net_admin+ep`, macOS root, Windows -[Npcap](https://npcap.com). Les flux capturés proviennent de l'équipement -`127.0.0.1`. +les liste, `-capture eth1` capture. Sous Windows : + +``` +traffic66.exe interfaces # list the network cards: name, number, address +traffic66.exe -capture Wi-Fi # capture on the wireless card (or by number: -capture 2) +``` + +Linux demande root ou `setcap cap_net_raw,cap_net_admin+ep`, macOS root, +Windows [Npcap](https://npcap.com). Les flux capturés proviennent de +l'équipement `127.0.0.1`. La capture locale n'a ni interfaces ni compteurs +d'équipement, le **Contrôle des interfaces** n'a donc rien à comparer pour +elle. diff --git a/docs/README.hi.md b/docs/README.hi.md index 4e2f361..231c07e 100644 --- a/docs/README.hi.md +++ b/docs/README.hi.md @@ -352,8 +352,10 @@ NetFlow/IPFIX के साथ उतनी देर तक जितनी द **ऑफ़लाइन pcap विश्लेषण** पैकेट कैप्चर (pcap, pcapng) को उन्हीं पेजों पर दिखाता है, लाइव डेटा से अलग: `traffic66 a.pcap b.pcapng` 127.0.0.1 पर शुरू होकर browser खोलता है (अधिकतम 3 फ़ाइलें, 3 GB; Ctrl+C आयात किया डेटा मिटा देता -है), या उस पेज पर 50 MB तक की अधिकतम 3 फ़ाइलें अपलोड करें। यह flows पर काम -करता है, पैकेट की सामग्री पर नहीं। +है), या उस पेज पर 50 MB तक की अधिकतम 3 फ़ाइलें अपलोड करें। एक समय में एक +फ़ाइल का विश्लेषण होता है, हर फ़ाइल अपने अलग database में: किसी फ़ाइल की +पंक्ति पर **विश्लेषण करें** उसे हर पेज पर दिखाता है, और ऊपर की पट्टी दूसरी +फ़ाइल पर बदल देती है। यह flows पर काम करता है, पैकेट की सामग्री पर नहीं।  @@ -368,9 +370,17 @@ browser में खोलें, q बाहर निकलें; `-lang` **Local capture** किसी local interface से flows बनाता है, सबसे अच्छा किसी switch के mirror port से जुड़ा port: `traffic66 interfaces` उनकी सूची देता है, -`-capture eth1` (या Windows का नाम या नंबर) capture करता है। Linux को root या -`setcap cap_net_raw,cap_net_admin+ep` चाहिए, macOS को root, Windows को -[Npcap](https://npcap.com)। Capture किए गए flows डिवाइस `127.0.0.1` से आते हैं। +`-capture eth1` capture करता है। Windows पर: + +``` +traffic66.exe interfaces # list the network cards: name, number, address +traffic66.exe -capture Wi-Fi # capture on the wireless card (or by number: -capture 2) +``` + +Linux को root या `setcap cap_net_raw,cap_net_admin+ep` चाहिए, macOS को root, +Windows को [Npcap](https://npcap.com)। Capture किए गए flows डिवाइस `127.0.0.1` +से आते हैं। Local capture में डिवाइस के interfaces या counters नहीं होते, +इसलिए **इंटरफ़ेस मिलान** के पास इसके लिए तुलना करने को कुछ नहीं है। diff --git a/docs/README.id.md b/docs/README.id.md index 65c11fb..d48c996 100644 --- a/docs/README.id.md +++ b/docs/README.id.md @@ -357,7 +357,10 @@ kecil bisa tersembunyi di balik sampling. halaman yang sama, terpisah dari data langsung: `traffic66 a.pcap b.pcapng` berjalan di 127.0.0.1 dan membuka browser (maksimal 3 file, 3 GB; Ctrl+C menghapus data yang diimpor), atau unggah maksimal 3 file berukuran 50 MB di -halaman itu. Analisis bekerja pada flow, bukan isi paket. +halaman itu. Satu file dianalisis pada satu waktu, masing-masing dalam +database tersendiri: **Analisis** pada baris sebuah file menampilkannya di +semua halaman, dan bilah di bagian atas beralih ke file lain. Analisis +bekerja pada flow, bukan isi paket.  @@ -372,10 +375,18 @@ rentang waktu, w buka di browser, q keluar; `-lang` memilih bahasa. **Capture lokal** membuat flow dari interface lokal, paling baik port yang terhubung ke port mirror sebuah switch: `traffic66 interfaces` menampilkan -daftarnya, `-capture eth1` (atau nama atau nomor di Windows) melakukan -capture. Linux butuh root atau `setcap cap_net_raw,cap_net_admin+ep`, macOS -butuh root, Windows butuh [Npcap](https://npcap.com). Flow hasil capture -berasal dari perangkat `127.0.0.1`. +daftarnya, `-capture eth1` melakukan capture. Di Windows: + +``` +traffic66.exe interfaces # list the network cards: name, number, address +traffic66.exe -capture Wi-Fi # capture on the wireless card (or by number: -capture 2) +``` + +Linux butuh root atau `setcap cap_net_raw,cap_net_admin+ep`, macOS butuh +root, Windows butuh [Npcap](https://npcap.com). Flow hasil capture berasal +dari perangkat `127.0.0.1`. Capture lokal tidak punya interface atau counter +perangkat, jadi **Pencocokan antarmuka** tidak punya apa pun untuk +dibandingkan. diff --git a/docs/README.ja.md b/docs/README.ja.md index dc4d5e9..60b764e 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -272,7 +272,7 @@ snmp 192.0.2.9 s3cret 10.99.0.9:161 # other management address ## 9. オフライン pcap、ターミナル UI、ローカルキャプチャ -**オフライン pcap 分析** は、パケットキャプチャ(pcap、pcapng)をライブデータとは分けて、同じページで表示します。`traffic66 a.pcap b.pcapng` は 127.0.0.1 で起動してブラウザーを開きます(最大 3 ファイル、3 GB。Ctrl+C で取り込んだデータを削除)。そのページで 50 MB までのファイルを最大 3 つアップロードすることもできます。扱うのはフローで、パケットの中身ではありません。 +**オフライン pcap 分析** は、パケットキャプチャ(pcap、pcapng)をライブデータとは分けて、同じページで表示します。`traffic66 a.pcap b.pcapng` は 127.0.0.1 で起動してブラウザーを開きます(最大 3 ファイル、3 GB。Ctrl+C で取り込んだデータを削除)。そのページで 50 MB までのファイルを最大 3 つアップロードすることもできます。一度に分析するのは 1 ファイルで、それぞれ専用のデータベースに入ります。ファイルの行の **分析** でそのファイルが全ページに表示され、上部のバーで別のファイルに切り替えられます。扱うのはフローで、パケットの中身ではありません。  @@ -282,7 +282,14 @@ snmp 192.0.2.9 s3cret 10.99.0.9:161 # other management address  -**ローカルキャプチャ** はローカルのインターフェースからフローを作ります。スイッチのミラーポートにつないだポートが最適です。`traffic66 interfaces` で一覧を表示し、`-capture eth1`(または Windows の名前か番号)でキャプチャします。Linux では root か `setcap cap_net_raw,cap_net_admin+ep`、macOS では root、Windows では [Npcap](https://npcap.com) が必要です。キャプチャしたフローは機器 `127.0.0.1` からのものとして表示されます。 +**ローカルキャプチャ** はローカルのインターフェースからフローを作ります。スイッチのミラーポートにつないだポートが最適です。`traffic66 interfaces` で一覧を表示し、`-capture eth1` でキャプチャします。Windows では: + +``` +traffic66.exe interfaces # list the network cards: name, number, address +traffic66.exe -capture Wi-Fi # capture on the wireless card (or by number: -capture 2) +``` + +Linux では root か `setcap cap_net_raw,cap_net_admin+ep`、macOS では root、Windows では [Npcap](https://npcap.com) が必要です。キャプチャしたフローは機器 `127.0.0.1` からのものとして表示されます。ローカルキャプチャには機器のインターフェースもカウンターもないため、**インターフェース照合** で比較する対象はありません。 diff --git a/docs/README.ko.md b/docs/README.ko.md index 244fdfb..3bc3223 100644 --- a/docs/README.ko.md +++ b/docs/README.ko.md @@ -272,7 +272,7 @@ snmp 192.0.2.9 s3cret 10.99.0.9:161 # other management address ## 9. 오프라인 pcap, 터미널 UI, 로컬 캡처 -**오프라인 pcap 분석**은 패킷 캡처(pcap, pcapng)를 실시간 데이터와 따로, 같은 페이지로 보여 줍니다. `traffic66 a.pcap b.pcapng`는 127.0.0.1에서 시작해 브라우저를 엽니다(최대 3개 파일, 3 GB. Ctrl+C로 가져온 데이터를 삭제). 또는 그 페이지에서 50 MB까지의 파일을 최대 3개 업로드할 수 있습니다. 다루는 것은 플로이며, 패킷 내용이 아닙니다. +**오프라인 pcap 분석**은 패킷 캡처(pcap, pcapng)를 실시간 데이터와 따로, 같은 페이지로 보여 줍니다. `traffic66 a.pcap b.pcapng`는 127.0.0.1에서 시작해 브라우저를 엽니다(최대 3개 파일, 3 GB. Ctrl+C로 가져온 데이터를 삭제). 또는 그 페이지에서 50 MB까지의 파일을 최대 3개 업로드할 수 있습니다. 한 번에 한 파일씩, 파일마다 별도의 데이터베이스에서 분석합니다. 파일 행의 **분석**을 누르면 모든 페이지에 그 파일이 표시되고, 상단 바에서 다른 파일로 전환합니다. 다루는 것은 플로이며, 패킷 내용이 아닙니다.  @@ -282,7 +282,14 @@ snmp 192.0.2.9 s3cret 10.99.0.9:161 # other management address  -**로컬 캡처**는 로컬 인터페이스에서 플로를 만듭니다. 스위치의 미러 포트에 연결한 포트가 가장 좋습니다. `traffic66 interfaces`로 목록을 보고, `-capture eth1`(또는 Windows의 이름이나 번호)로 캡처합니다. Linux는 root 또는 `setcap cap_net_raw,cap_net_admin+ep`, macOS는 root, Windows는 [Npcap](https://npcap.com)이 필요합니다. 캡처한 플로는 장비 `127.0.0.1`에서 온 것으로 표시됩니다. +**로컬 캡처**는 로컬 인터페이스에서 플로를 만듭니다. 스위치의 미러 포트에 연결한 포트가 가장 좋습니다. `traffic66 interfaces`로 목록을 보고, `-capture eth1`로 캡처합니다. Windows에서는: + +``` +traffic66.exe interfaces # list the network cards: name, number, address +traffic66.exe -capture Wi-Fi # capture on the wireless card (or by number: -capture 2) +``` + +Linux는 root 또는 `setcap cap_net_raw,cap_net_admin+ep`, macOS는 root, Windows는 [Npcap](https://npcap.com)이 필요합니다. 캡처한 플로는 장비 `127.0.0.1`에서 온 것으로 표시됩니다. 로컬 캡처에는 장비 인터페이스나 카운터가 없으므로 **인터페이스 대조**에서 비교할 것이 없습니다. diff --git a/docs/README.pt.md b/docs/README.pt.md index 8097a2b..8aa5bf1 100644 --- a/docs/README.pt.md +++ b/docs/README.pt.md @@ -357,8 +357,11 @@ muito pequenas podem se esconder atrás da amostragem. **Análise offline de pcap** mostra capturas de pacotes (pcap, pcapng) com as mesmas páginas, separadas dos dados ao vivo: `traffic66 a.pcap b.pcapng` inicia em 127.0.0.1 e abre o navegador (até 3 arquivos, 3 GB; Ctrl+C apaga -os dados importados), ou envie até 3 arquivos de 50 MB nessa página. Ela -trabalha com fluxos, não com o conteúdo dos pacotes. +os dados importados), ou envie até 3 arquivos de 50 MB nessa página. Um +arquivo é analisado por vez, cada um em seu próprio banco de dados: +**Analisar** na linha de um arquivo o mostra em todas as páginas, e a barra +no topo troca para outro. Ela trabalha com fluxos, não com o conteúdo dos +pacotes.  @@ -373,10 +376,18 @@ tempo, w abrir no navegador, q sair; `-lang` escolhe o idioma. **Captura local** gera fluxos a partir de uma interface local, de preferência uma porta ligada à porta espelho de um switch: -`traffic66 interfaces` as lista, `-capture eth1` (ou um nome ou número do -Windows) captura. O Linux precisa de root ou -`setcap cap_net_raw,cap_net_admin+ep`, o macOS de root, o Windows do -[Npcap](https://npcap.com). Os fluxos capturados vêm do equipamento `127.0.0.1`. +`traffic66 interfaces` as lista, `-capture eth1` captura. No Windows: + +``` +traffic66.exe interfaces # list the network cards: name, number, address +traffic66.exe -capture Wi-Fi # capture on the wireless card (or by number: -capture 2) +``` + +O Linux precisa de root ou `setcap cap_net_raw,cap_net_admin+ep`, o macOS de +root, o Windows do [Npcap](https://npcap.com). Os fluxos capturados vêm do +equipamento `127.0.0.1`. A captura local não tem interfaces nem contadores +do equipamento, então a **Conferência de interfaces** não tem nada a +comparar para ela. diff --git a/docs/README.ru.md b/docs/README.ru.md index 8d611da..195c9cb 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -359,7 +359,9 @@ Geolocation by DB-IP", [db-ip.com](https://db-ip.com)); **Настройки** страницах, отдельно от живых данных: `traffic66 a.pcap b.pcapng` запускается на 127.0.0.1 и открывает браузер (до 3 файлов, 3 ГБ; Ctrl+C удаляет импортированные данные), или загрузите на этой странице до 3 файлов по -50 МБ. Анализируются потоки, а не содержимое пакетов. +50 МБ. Файлы анализируются по одному, у каждого своя база данных: кнопка +**Анализ** в строке файла показывает его на всех страницах, а панель вверху +переключает на другой. Анализируются потоки, а не содержимое пакетов.  @@ -374,10 +376,17 @@ t период, w открыть в браузере, q выход; `-lang` вы **Локальный захват** строит потоки с локального интерфейса, лучше всего с порта, подключённого к зеркальному порту коммутатора: `traffic66 interfaces` -выводит их список, `-capture eth1` (или имя либо номер в Windows) -захватывает. В Linux нужны root или `setcap cap_net_raw,cap_net_admin+ep`, -в macOS — root, в Windows — [Npcap](https://npcap.com). Захваченные потоки -приходят от устройства `127.0.0.1`. +выводит их список, `-capture eth1` захватывает. В Windows: + +``` +traffic66.exe interfaces # list the network cards: name, number, address +traffic66.exe -capture Wi-Fi # capture on the wireless card (or by number: -capture 2) +``` + +В Linux нужны root или `setcap cap_net_raw,cap_net_admin+ep`, в macOS — root, +в Windows — [Npcap](https://npcap.com). Захваченные потоки приходят от +устройства `127.0.0.1`. У локального захвата нет интерфейсов и счётчиков +устройства, поэтому в **Сверке интерфейсов** для него нечего сравнивать. diff --git a/docs/README.ur.md b/docs/README.ur.md index b9c3c62..dbf71bd 100644 --- a/docs/README.ur.md +++ b/docs/README.ur.md @@ -354,8 +354,10 @@ Threat lists متن کی فائلیں ہیں، ہر لائن میں ایک addre **آف لائن pcap تجزیہ** پیکٹ کیپچر (pcap، pcapng) کو انہی صفحات پر دکھاتا ہے، لائیو ڈیٹا سے الگ: `traffic66 a.pcap b.pcapng` 127.0.0.1 پر شروع ہوتا ہے اور browser کھولتا ہے (زیادہ سے زیادہ 3 فائلیں، 3 GB؛ Ctrl+C امپورٹ شدہ ڈیٹا حذف کر دیتا -ہے)، یا اسی صفحے پر 50 MB تک کی 3 فائلیں اپ لوڈ کریں۔ یہ flows پر کام کرتا ہے، -پیکٹ کے مواد پر نہیں۔ +ہے)، یا اسی صفحے پر 50 MB تک کی 3 فائلیں اپ لوڈ کریں۔ ایک وقت میں ایک فائل کا +تجزیہ ہوتا ہے، ہر فائل اپنے الگ ڈیٹابیس میں: کسی فائل کی قطار پر +**تجزیہ کریں** اسے ہر صفحے پر دکھاتا ہے، اور اوپر کی پٹی دوسری فائل پر بدل دیتی +ہے۔ یہ flows پر کام کرتا ہے، پیکٹ کے مواد پر نہیں۔  @@ -370,9 +372,17 @@ browser میں کھولیں، q باہر نکلیں؛ `-lang` زبان چنتا **Local capture** ایک local interface سے flows بناتا ہے، بہترین ہے کہ وہ کسی switch کے mirror port سے جڑا port ہو: `traffic66 interfaces` ان کی فہرست دیتا -ہے، `-capture eth1` (یا Windows کا نام یا نمبر) capture کرتا ہے۔ Linux کو root یا -`setcap cap_net_raw,cap_net_admin+ep` چاہیے، macOS کو root، Windows کو -[Npcap](https://npcap.com)۔ capture شدہ flows ڈیوائس `127.0.0.1` سے آتے ہیں۔ +ہے، `-capture eth1` capture کرتا ہے۔ Windows پر: + +``` +traffic66.exe interfaces # list the network cards: name, number, address +traffic66.exe -capture Wi-Fi # capture on the wireless card (or by number: -capture 2) +``` + +Linux کو root یا `setcap cap_net_raw,cap_net_admin+ep` چاہیے، macOS کو root، +Windows کو [Npcap](https://npcap.com)۔ capture شدہ flows ڈیوائس `127.0.0.1` سے +آتے ہیں۔ Local capture میں ڈیوائس کے interfaces یا counters نہیں ہوتے، اس لیے +**انٹرفیس جانچ** کے پاس اس کے لیے موازنہ کرنے کو کچھ نہیں ہے۔ diff --git a/docs/README.zh.md b/docs/README.zh.md index 45e78a0..4502d89 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -272,7 +272,7 @@ snmp 192.0.2.9 s3cret 10.99.0.9:161 # other management address ## 9. 离线 pcap、终端界面、本地抓包 -**离线 pcap 分析** 用相同的页面查看抓包文件(pcap、pcapng),与实时数据分开:`traffic66 a.pcap b.pcapng` 在 127.0.0.1 上启动并打开浏览器(最多 3 个文件,3 GB;按 Ctrl+C 删除导入的数据),也可以在该页面上传最多 3 个 50 MB 的文件。它分析的是流,而不是包内容。 +**离线 pcap 分析** 用相同的页面查看抓包文件(pcap、pcapng),与实时数据分开:`traffic66 a.pcap b.pcapng` 在 127.0.0.1 上启动并打开浏览器(最多 3 个文件,3 GB;按 Ctrl+C 删除导入的数据),也可以在该页面上传最多 3 个 50 MB 的文件。一次分析一个文件,每个文件有自己独立的数据库:在文件所在行点 **分析**,所有页面都显示该文件,顶部的栏可切换到另一个文件。它分析的是流,而不是包内容。  @@ -282,7 +282,14 @@ snmp 192.0.2.9 s3cret 10.99.0.9:161 # other management address  -**本地抓包** 从本机网卡生成流,最好是接到交换机镜像端口的网口:`traffic66 interfaces` 列出网卡,`-capture eth1`(或 Windows 的名称或编号)开始抓包。Linux 需要 root 或 `setcap cap_net_raw,cap_net_admin+ep`,macOS 需要 root,Windows 需要 [Npcap](https://npcap.com)。抓到的流显示为来自设备 `127.0.0.1`。 +**本地抓包** 从本机网卡生成流,最好是接到交换机镜像端口的网口:`traffic66 interfaces` 列出网卡,`-capture eth1` 开始抓包。在 Windows 上: + +``` +traffic66.exe interfaces # list the network cards: name, number, address +traffic66.exe -capture Wi-Fi # capture on the wireless card (or by number: -capture 2) +``` + +Linux 需要 root 或 `setcap cap_net_raw,cap_net_admin+ep`,macOS 需要 root,Windows 需要 [Npcap](https://npcap.com)。抓到的流显示为来自设备 `127.0.0.1`。本地抓包没有设备接口和计数器,因此 **接口对账** 对它没有可对比的内容。 diff --git a/docs/images/sandbox.png b/docs/images/sandbox.png index e48ed47..0682f9f 100644 Binary files a/docs/images/sandbox.png and b/docs/images/sandbox.png differ diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go index bf58ebc..468c150 100644 --- a/internal/api/sandbox.go +++ b/internal/api/sandbox.go @@ -78,3 +78,16 @@ func (s *Server) deleteSandbox(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, s.SB.Info()) } + +// putSandboxActive makes one capture file the one the pages show. +func (s *Server) putSandboxActive(w http.ResponseWriter, r *http.Request) { + if s.SB == nil { + fail(w, errors.New("offline analysis is not available")) + return + } + if err := s.SB.Select(r.URL.Query().Get("name")); err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, s.SB.Info()) +} diff --git a/internal/api/server.go b/internal/api/server.go index 6a1d826..421d483 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -112,6 +112,7 @@ func (s *Server) Handler() http.Handler { api("GET /api/sandbox", s.getSandbox) api("POST /api/sandbox/files", s.putSandboxFile) api("DELETE /api/sandbox/files", s.deleteSandbox) + api("POST /api/sandbox/active", s.putSandboxActive) api("POST /api/logo", s.putLogo) api("DELETE /api/logo", s.deleteLogo) api("GET /api/geo", s.getGeo) diff --git a/internal/capture/capture.go b/internal/capture/capture.go index 600e067..d4eeb62 100644 --- a/internal/capture/capture.go +++ b/internal/capture/capture.go @@ -38,6 +38,7 @@ type entry struct { first, last time.Time pkts, bytes uint64 flags uint8 + opener int8 // kept across active-timeout exports } // Capture is one running interface capture. @@ -111,7 +112,7 @@ func (c *Capture) readLoop(ctx context.Context) { c.Dropped.Add(1) return } - e = &entry{first: now} + e = &entry{first: now, opener: opener(r.TCPFlags)} c.table[k] = e } e.last = now @@ -145,6 +146,15 @@ func (c *Capture) expireLoop(ctx context.Context) { func (c *Capture) flush(now time.Time, all bool) { var out []flow.Record c.mu.Lock() + // first learn who opened each connection from either direction, before + // any entry is removed below + for k, e := range c.table { + if e.opener == 0 && e.pkts > 0 { + if re := c.table[key{k.dst, k.src, k.dport, k.sport, k.proto, k.vlan, k.encap}]; re != nil { + e.opener = -re.opener + } + } + } for k, e := range c.table { idle := now.Sub(e.last) >= idleTimeout active := now.Sub(e.first) >= activeTimeout @@ -157,15 +167,29 @@ func (c *Capture) flush(now time.Time, all bool) { } continue } + op := e.opener out = append(out, flow.Record{Start: e.first, End: e.last, Src: k.src, Dst: k.dst, SrcPort: k.sport, DstPort: k.dport, - Proto: k.proto, VLAN: k.vlan, Encap: k.encap, TCPFlags: e.flags, Bytes: e.bytes, Packets: e.pkts, L2: true, + Proto: k.proto, VLAN: k.vlan, Encap: k.encap, TCPFlags: e.flags, Bytes: e.bytes, Packets: e.pkts, L2: true, Opener: op, Sampling: 1, Mult: 1, SamplingKnown: true, Exporter: Exporter, Domain: c.domain, Source: flow.SrcCapture, Direction: 255}) if idle || all { delete(c.table, k) } else { - *e = entry{first: now, last: now} + *e = entry{first: now, last: now, opener: op} } } c.mu.Unlock() c.sink.Submit(out) } + +// opener reads the first packet of a TCP connection seen in one direction: +// a SYN alone opens it (1), a SYN+ACK answers (-1); anything else says +// nothing (0). +func opener(flags uint8) int8 { + switch flags & 0x12 { + case 0x02: + return 1 + case 0x12: + return -1 + } + return 0 +} diff --git a/internal/enrich/role_test.go b/internal/enrich/role_test.go new file mode 100644 index 0000000..1aa9282 --- /dev/null +++ b/internal/enrich/role_test.go @@ -0,0 +1,31 @@ +package enrich + +import ( + "net/netip" + "testing" + + "github.com/githubflyideas/traffic66/internal/flow" +) + +// Exported TCP records carry the flags of all their packets: both +// directions of a normal connection read SYN+ACK. Both must agree on who +// the client is. +func TestRoleMergedFlags(t *testing.T) { + pc, srv := netip.MustParseAddr("10.0.0.5"), netip.MustParseAddr("20.27.177.113") + up := flow.Record{Src: pc, Dst: srv, SrcPort: 52114, DstPort: 443, Proto: 6, TCPFlags: 0x1b, Packets: 40} + down := flow.Record{Src: srv, Dst: pc, SrcPort: 443, DstPort: 52114, Proto: 6, TCPFlags: 0x1b, Packets: 60} + if !Role(&up) || Role(&down) { + t.Fatalf("up=%v down=%v, want the PC as client both ways", Role(&up), Role(&down)) + } + // both ports unknown: the opener seen in the first packet decides + a := flow.Record{Src: pc, Dst: srv, SrcPort: 40000, DstPort: 50000, Proto: 6, TCPFlags: 0x1b, Packets: 9, Opener: 1} + b := flow.Record{Src: srv, Dst: pc, SrcPort: 50000, DstPort: 40000, Proto: 6, TCPFlags: 0x1b, Packets: 9, Opener: -1} + if !Role(&a) || Role(&b) { + t.Fatalf("opener ignored: a=%v b=%v", Role(&a), Role(&b)) + } + // a single sampled packet still reads its flags + syn := flow.Record{Src: pc, Dst: srv, SrcPort: 50000, DstPort: 40000, Proto: 6, TCPFlags: 0x02, Packets: 1} + if !Role(&syn) { + t.Fatal("a lone SYN opens the connection") + } +} diff --git a/internal/enrich/services.go b/internal/enrich/services.go index eb7ce92..3d3ebc6 100644 --- a/internal/enrich/services.go +++ b/internal/enrich/services.go @@ -60,10 +60,17 @@ func known(proto uint8, port uint16) bool { func Role(r *flow.Record) bool { switch r.Proto { case 6: - if r.TCPFlags&0x12 == 0x02 { + if r.Opener != 0 { + return r.Opener > 0 // the first packet of the connection was seen + } + // The flags of a record are those of all its packets together: a + // client's SYN followed by ACKs reads as SYN+ACK just like the + // server's answer. They tell the roles only for a single packet + // (an sFlow sample, a lone SYN of a scan). + if r.Packets == 1 && r.TCPFlags&0x12 == 0x02 { return true // SYN without ACK: source opened the connection } - if r.TCPFlags&0x12 == 0x12 { + if r.Packets == 1 && r.TCPFlags&0x12 == 0x12 { return false // SYN+ACK: source is answering } case 17, 132: diff --git a/internal/flow/flow.go b/internal/flow/flow.go index 1c981f0..ef59e44 100644 --- a/internal/flow/flow.go +++ b/internal/flow/flow.go @@ -85,6 +85,12 @@ type Record struct { Encap Encap // Direction as reported by the exporter: 0 ingress, 1 egress, 255 unknown. Direction uint8 + // Opener says who opened a TCP connection, when the packets showed it + // (local capture and pcap files see the first packet of each + // direction): 1 the source sent the SYN, -1 the source answered it with + // SYN+ACK, 0 unknown. Exported records carry only the merged flags of + // all packets, which cannot tell. + Opener int8 SrcAS, DstAS uint32 diff --git a/internal/sandbox/flows.go b/internal/sandbox/flows.go index 4e3fbf9..7fa3ca5 100644 --- a/internal/sandbox/flows.go +++ b/internal/sandbox/flows.go @@ -34,6 +34,7 @@ type fentry struct { first, last time.Time pkts, bytes uint64 flags uint8 + opener int8 // see flow.Record.Opener; kept across active-timeout exports } const ( @@ -89,6 +90,12 @@ func (b *builder) add(p *pcapfile.Packet) { b.sweep(p.Time, true) } e = &fentry{first: p.Time} + switch r.TCPFlags & 0x12 { + case 0x02: + e.opener = 1 + case 0x12: + e.opener = -1 + } b.table[k] = e } if p.Time.After(e.last) { @@ -111,6 +118,15 @@ func (b *builder) add(p *pcapfile.Packet) { func (b *builder) sweep(now time.Time, all bool) { b.swept = now var out []flow.Record + // first learn who opened each connection from either direction, before + // any entry is removed below + for k, e := range b.table { + if e.opener == 0 && e.pkts > 0 { + if re := b.table[fkey{k.dst, k.src, k.dport, k.sport, k.proto, k.vlan, k.encap, k.l2}]; re != nil { + e.opener = -re.opener + } + } + } for k, e := range b.table { idle := now.Sub(e.last) >= idleTimeout active := now.Sub(e.first) >= activeTimeout @@ -118,14 +134,14 @@ func (b *builder) sweep(now time.Time, all bool) { continue } if e.pkts > 0 { - out = append(out, flow.Record{Start: e.first, End: e.last, Src: k.src, Dst: k.dst, SrcPort: k.sport, DstPort: k.dport, + out = append(out, flow.Record{Start: e.first, End: e.last, Src: k.src, Dst: k.dst, SrcPort: k.sport, DstPort: k.dport, Opener: e.opener, Proto: k.proto, VLAN: k.vlan, Encap: k.encap, TCPFlags: e.flags, Bytes: e.bytes, Packets: e.pkts, L2: k.l2, Sampling: 1, Mult: 1, SamplingKnown: true, Exporter: b.exporter, Domain: b.domain, Source: flow.SrcCapture, Direction: 255}) } if idle || all { delete(b.table, k) } else { - *e = fentry{first: now, last: now} + *e = fentry{first: now, last: now, opener: e.opener} } } if len(out) > 0 { diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 3121c8f..8f830ef 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -58,7 +58,9 @@ type File struct { Path string `json:"path,omitempty"` // a file opened in place (never deleted); else it is in Dir/files } -// Sandbox holds the uploaded files and their database. +// Sandbox holds the uploaded files. Each file has a database of its own, so +// one capture's flows and findings never mix with another's; the pages show +// one file at a time, the active one. type Sandbox struct { Dir string Inv *enrich.Inventory // the live inventory: names of networks and hosts @@ -72,14 +74,15 @@ type Sandbox struct { work sync.Mutex closed bool - mu sync.Mutex - files []*File - st *store.Store - det *detect.Detector - inv *enrich.Inventory // live inventory plus a device per file - busy bool - gen int // bumped by every rebuild; stale imports stop - wake chan struct{} + mu sync.Mutex + files []*File + active string // the file the pages show + st *store.Store // the active file's database, nil when not open + det *detect.Detector // its detection rules + inv *enrich.Inventory // live inventory plus a device per file + busy bool + gen int // bumped by every rebuild; stale imports stop + wake chan struct{} } // ErrLimit is returned when a file would exceed the limits. @@ -100,13 +103,6 @@ func NewWith(dir string, inv *enrich.Inventory, asn *enrich.ASNDB, thr *enrich.T sb.load() go sb.worker() if len(sb.files) > 0 { - sb.mu.Lock() - if sb.st == nil { // database missing: rebuild from the files - for _, f := range sb.files { - f.Status = "waiting" - } - } - sb.mu.Unlock() sb.kick() } return sb @@ -121,9 +117,23 @@ func (sb *Sandbox) filePath(f *File) string { } return filepath.Join(sb.pcapDir(), f.Name) } -func (sb *Sandbox) dbDir() string { return filepath.Join(sb.Dir, "db") } -func (sb *Sandbox) indexPath() string { - return filepath.Join(sb.Dir, "files.json") +func (sb *Sandbox) dbRoot() string { return filepath.Join(sb.Dir, "db") } +func (sb *Sandbox) dbDir(f *File) string { return filepath.Join(sb.dbRoot(), f.Exporter) } +func (sb *Sandbox) indexPath() string { return filepath.Join(sb.Dir, "files.json") } +func (sb *Sandbox) activePath() string { return filepath.Join(sb.Dir, "active") } +func (sb *Sandbox) hasDB(f *File) bool { + _, err := os.Stat(filepath.Join(sb.dbDir(f), "traffic66.duckdb")) + return err == nil +} + +// file returns the file of that name; sb.mu held. +func (sb *Sandbox) file(name string) *File { + for _, f := range sb.files { + if f.Name == name { + return f + } + } + return nil } func (sb *Sandbox) load() { @@ -140,16 +150,49 @@ func (sb *Sandbox) load() { sb.files = append(sb.files, f) } } - if _, err := os.Stat(filepath.Join(sb.dbDir(), "traffic66.duckdb")); err == nil { - if err := sb.open(); err != nil { + // before 1.5.2 all files shared one database: import them again + if _, err := os.Stat(filepath.Join(sb.dbRoot(), "traffic66.duckdb")); err == nil { + os.RemoveAll(sb.dbRoot()) + } + for _, f := range sb.files { + if f.Status != "error" && (f.Status != "done" || !sb.hasDB(f)) { + f.Status = "waiting" + } + } + if b, err := os.ReadFile(sb.activePath()); err == nil { + sb.active = strings.TrimSpace(string(b)) + } + sb.pickActive() + if f := sb.file(sb.active); f != nil && f.Status == "done" { + if err := sb.openActive(); err != nil { log.Printf("sandbox: %v", err) } } +} + +// pickActive keeps the active file if it is still there, else takes the +// first imported one; sb.mu held. +func (sb *Sandbox) pickActive() { + if f := sb.file(sb.active); f != nil && f.Status != "error" { + return + } + sb.active = "" for _, f := range sb.files { - if f.Status != "done" && f.Status != "error" { - f.Status = "waiting" + if f.Status == "done" { + sb.active = f.Name + break } } + sb.saveActive() +} + +func (sb *Sandbox) saveActive() { + if sb.active == "" { + os.Remove(sb.activePath()) + return + } + os.MkdirAll(sb.Dir, 0o755) + os.WriteFile(sb.activePath(), []byte(sb.active), 0o644) } func (sb *Sandbox) save() { @@ -161,13 +204,22 @@ func (sb *Sandbox) save() { } } -// open opens (or creates) the database; sb.mu held or not yet shared. -func (sb *Sandbox) open() error { +// openDB opens (or creates) one file's database. +func (sb *Sandbox) openDB(f *File) (*store.Store, error) { threads := 2 // beside live collection if sb.Mem > 0.1 { threads = 0 // on its own: all but one core } - st, err := store.Open(store.Options{Dir: sb.dbDir(), MemoryFraction: sb.Mem, Threads: threads}) + return store.Open(store.Options{Dir: sb.dbDir(f), MemoryFraction: sb.Mem, Threads: threads}) +} + +// openActive opens the active file's database for the pages; sb.mu held. +func (sb *Sandbox) openActive() error { + f := sb.file(sb.active) + if f == nil { + return errors.New("no capture file chosen") + } + st, err := sb.openDB(f) if err != nil { return err } @@ -177,6 +229,34 @@ func (sb *Sandbox) open() error { return nil } +// closeActive closes the pages' database; sb.mu held. +func (sb *Sandbox) closeActive() { + if sb.st != nil { + sb.st.Close() + } + sb.st, sb.det = nil, nil +} + +// Select makes the file of that name the one the pages show. +func (sb *Sandbox) Select(name string) error { + sb.mu.Lock() + defer sb.mu.Unlock() + f := sb.file(name) + if f == nil { + return errors.New("no such file") + } + if f.Status != "done" { + return errors.New("the file is not imported yet") + } + if sb.active == name && sb.st != nil { + return nil + } + sb.closeActive() + sb.active = name + sb.saveActive() + return sb.openActive() +} + // refreshInventory names each file's exporter after the file. func (sb *Sandbox) refreshInventory() { inv := enrich.NewInventory() @@ -197,7 +277,7 @@ func (sb *Sandbox) refreshInventory() { } } -// Store returns the sandbox database, or nil when there is none. +// Store returns the active file's database, or nil when there is none. func (sb *Sandbox) Store() (*store.Store, *detect.Detector, *enrich.Inventory) { sb.mu.Lock() defer sb.mu.Unlock() @@ -206,10 +286,11 @@ func (sb *Sandbox) Store() (*store.Store, *detect.Detector, *enrich.Inventory) { // Info describes the sandbox for the UI. type Info struct { - Files []File `json:"files"` - Busy bool `json:"busy"` - First time.Time `json:"first"` - Last time.Time `json:"last"` + Files []File `json:"files"` + Busy bool `json:"busy"` + Active string `json:"active"` // the file the pages show + First time.Time `json:"first"` // its time span + Last time.Time `json:"last"` Limits Ready bool `json:"ready"` // has data to look at } @@ -217,19 +298,13 @@ type Info struct { func (sb *Sandbox) Info() Info { sb.mu.Lock() defer sb.mu.Unlock() - in := Info{Files: []File{}, Busy: sb.busy, Limits: sb.Lim} + in := Info{Files: []File{}, Busy: sb.busy, Limits: sb.Lim, Active: sb.active} for _, f := range sb.files { in.Files = append(in.Files, *f) - if f.Flows == 0 { - continue - } - if in.First.IsZero() || f.First.Before(in.First) { - in.First = f.First - } - if f.Last.After(in.Last) { - in.Last = f.Last + if f.Name == sb.active && f.Flows > 0 { + in.First, in.Last = f.First, f.Last + in.Ready = sb.st != nil } - in.Ready = sb.st != nil } return in } @@ -408,15 +483,11 @@ func slicesDelete(fs []*File, f *File) []*File { return out } -// Delete removes one file (name) or everything (name ""). The database is -// rebuilt from the remaining files. +// Delete removes one file (name) or everything (name ""), with its +// database. The other files keep theirs. func (sb *Sandbox) Delete(name string) error { sb.mu.Lock() - found := name == "" - for _, f := range sb.files { - found = found || f.Name == name - } - if !found { + if name != "" && sb.file(name) == nil { sb.mu.Unlock() return errors.New("no such file") } @@ -433,31 +504,32 @@ func (sb *Sandbox) Delete(name string) error { keep = append(keep, f) continue } + if f.Name == sb.active { + sb.closeActive() + } if f.Path == "" { os.Remove(filepath.Join(sb.pcapDir(), f.Name)) } + os.RemoveAll(sb.dbDir(f)) continue } + if f.Status == "importing" { // stopped by the delete: start it again + f.Status = "waiting" + } keep = append(keep, f) } sb.files = keep - st := sb.st - sb.st, sb.det = nil, nil - for _, f := range sb.files { - if f.Status != "uploading" { - f.Status, f.Error, f.Packets, f.Skipped, f.Flows, f.First, f.Last = "waiting", "", 0, 0, 0, time.Time{}, time.Time{} + sb.pickActive() + if sb.st == nil && sb.active != "" { + if err := sb.openActive(); err != nil { + log.Printf("sandbox: %v", err) } } sb.save() sb.refreshInventory() + empty := len(keep) == 0 sb.mu.Unlock() - if st != nil { - st.Close() - } - if err := os.RemoveAll(sb.dbDir()); err != nil { - return err - } - if len(keep) == 0 { + if empty { os.RemoveAll(sb.Dir) } sb.kick() @@ -504,17 +576,25 @@ func (sb *Sandbox) importNext() bool { sb.mu.Unlock() return false } - if sb.st == nil { - if err := sb.open(); err != nil { - f.Status, f.Error = "error", err.Error() - sb.save() - sb.mu.Unlock() - return true - } + // a fresh database of its own + if f.Name == sb.active { + sb.closeActive() + } + os.RemoveAll(sb.dbDir(f)) + st, err := sb.openDB(f) + if err != nil { + f.Status, f.Error = "error", err.Error() + sb.save() + sb.mu.Unlock() + return true } f.Status = "importing" sb.busy = true - gen, st, det, inv := sb.gen, sb.st, sb.det, sb.inv + if sb.inv == nil { + sb.refreshInventory() + } + gen, inv := sb.gen, sb.inv + det := detect.New(st, inv, detect.Config{}) sb.mu.Unlock() res, err := sb.importFile(gen, f, st, inv) @@ -531,10 +611,11 @@ func (sb *Sandbox) importNext() bool { } } } + st.Close() sb.mu.Lock() defer sb.mu.Unlock() sb.busy = false - if gen != sb.gen { // deleted meanwhile + if gen != sb.gen { // deleted or stopped meanwhile return true } f.Packets, f.Skipped, f.Flows, f.First, f.Last = res.packets, res.skipped, res.flows, res.first, res.last @@ -546,6 +627,15 @@ func (sb *Sandbox) importNext() bool { default: f.Status = "done" } + if f.Status == "done" && (sb.active == "" || sb.active == f.Name) { + sb.active = f.Name + sb.saveActive() + if sb.st == nil { + if err := sb.openActive(); err != nil { + log.Printf("sandbox: %v", err) + } + } + } sb.save() log.Printf("sandbox: %s: %d packets, %d flows, %s – %s (%v)", f.Name, res.packets, res.flows, res.first.Format(time.RFC3339), res.last.Format(time.RFC3339), err) @@ -628,10 +718,6 @@ func (sb *Sandbox) Close() { sb.work.Lock() // wait for a running import to stop defer sb.work.Unlock() sb.mu.Lock() - st := sb.st - sb.st = nil + sb.closeActive() sb.mu.Unlock() - if st != nil { - st.Close() - } } diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go index 4f9e1d2..aea5da3 100644 --- a/internal/sandbox/sandbox_test.go +++ b/internal/sandbox/sandbox_test.go @@ -153,3 +153,60 @@ func TestAddPath(t *testing.T) { t.Errorf("the original file was deleted: %v", err) } } + +// Two captures are analysed one at a time: each has its own flows and +// findings, the first imported is shown first, and switching shows the +// other one alone. +func TestOneFileAtATime(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sandbox") + sb := New(dir, enrich.NewInventory(), enrich.NewASNDB(), enrich.NewThreats()) + var a, b bytes.Buffer + Sample(&a, time.Date(2026, 9, 20, 11, 0, 0, 0, time.UTC)) + Sample(&b, time.Date(2026, 9, 21, 11, 0, 0, 0, time.UTC)) + sb.Add("a.pcap", bytes.NewReader(a.Bytes()), false) + sb.Add("b.pcap", bytes.NewReader(b.Bytes()), false) + in := wait(t, sb) + if in.Active != "a.pcap" || !in.Ready || in.First.Day() != 20 { + t.Fatalf("active %q ready %v first %v", in.Active, in.Ready, in.First) + } + count := func() (n int, days string) { + st, _, _ := sb.Store() + st.DB.QueryRow(`SELECT count(*), string_agg(DISTINCT strftime(ts, '%d'), ',') FROM hot`).Scan(&n, &days) + return + } + if _, d := count(); d != "20" { + t.Errorf("a.pcap shows days %q", d) + } + if err := sb.Select("b.pcap"); err != nil { + t.Fatal(err) + } + if in := sb.Info(); in.Active != "b.pcap" || in.First.Day() != 21 { + t.Errorf("after switch: %q %v", in.Active, in.First) + } + if _, d := count(); d != "21" { + t.Errorf("b.pcap shows days %q", d) + } + // deleting the active file shows the other one; its data stays + if err := sb.Delete("b.pcap"); err != nil { + t.Fatal(err) + } + if in := sb.Info(); in.Active != "a.pcap" || !in.Ready { + t.Errorf("after delete: %q ready %v", in.Active, in.Ready) + } + if n, d := count(); n == 0 || d != "20" { + t.Errorf("a.pcap after delete: %d rows, days %q", n, d) + } + // a restart keeps the file and its database + sb.Close() + sb = New(dir, enrich.NewInventory(), enrich.NewASNDB(), enrich.NewThreats()) + defer sb.Close() + if in := wait(t, sb); in.Active != "a.pcap" || !in.Ready { + t.Errorf("after restart: %q ready %v", in.Active, in.Ready) + } + // a file added to a sandbox that has none open yet imports too + sb.Delete("") + sb.Add("b.pcap", bytes.NewReader(b.Bytes()), false) + if in := wait(t, sb); in.Active != "b.pcap" || !in.Ready { + t.Errorf("fresh: %q ready %v", in.Active, in.Ready) + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index ff3ae12..01e6458 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -365,3 +365,4 @@ label.btn.disabled{opacity:.5;cursor:not-allowed!important} .iflist .ifrow.peer>button:first-child{font-size:12px;color:var(--ink-3)} .iflist .ifrow.peer>button:first-child .muted{font-size:10.5px!important} .iflist .ifrow.peer>button:first-child .st{font-size:11px;opacity:.75} +#sbbar select{border:1px solid var(--line-2);border-radius:7px;padding:4px 8px;background:var(--surface);color:var(--ink);font-weight:600;max-width:260px} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 9cd1779..90c2f8e 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -214,6 +214,7 @@ async function renderIfsel() { w.hidden = !show; if (!show) return; const d = await loadIfaces(), list = d.ifaces || []; + if (!list.length) { w.hidden = true; return; } // e.g. local capture only: nothing to choose if (state.ifv === undefined) state.ifv = d.default || 'all'; const key = f => f.exporter + '/' + f.ifindex; const opt = (v, l, tt) => ``; @@ -1150,18 +1151,17 @@ views.sandbox = async (el) => {
${esc(t('sb.explain'))}
| ${t('sb.col_file')} | ${t('sb.col_size')} | ${t('sb.col_packets')} | ${t('sb.col_flows')} | ${t('sb.col_span')} | ${t('sb.col_status')} | |
|---|---|---|---|---|---|---|
| ${t('sb.empty')} | ||||||
${esc(sbInfo.max_total >= sbInfo.max_file_size * sbInfo.max_files ? t('sb.limits', {n: sbInfo.max_files, mb}) : sbInfo.max_total <= sbInfo.max_file_size ? t('sb.limits_total', {n: sbInfo.max_files, gb: fmtBytes(sbInfo.max_total)}) : t('sb.limits', {n: sbInfo.max_files, mb}) + ' ' + t('sb.limits_total', {n: sbInfo.max_files, gb: fmtBytes(sbInfo.max_total)}))} ${esc(t('sb.formats'))}
`)}