diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b9457..ccbda52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ heading is the version number. ## 0.3.1 +Offline analysis +- New page **Offline analysis**: upload packet captures (.pcap, .pcapng + from Wireshark or tcpdump; up to 3 files of at most 50 MB). They are + turned into flows in a database of their own, apart from the live data. + **Analyse** shows them on every page (overview, Top 66, traffic details, + findings, flow paths, map, flow records) with an orange bar naming the + files; **Back to live data** returns. **Delete** removes the files and + their data. +- The detection rules run over the capture: scans, port scans and password + guessing are found in it. +- The demo includes an example capture with an attack. + +Pages - Traffic details opens on servers only; tabs switch to clients, both ends side by side, and services (13 languages). diff --git a/README.md b/README.md index da1b22e..2e52026 100644 --- a/README.md +++ b/README.md @@ -652,6 +652,7 @@ Pages: | Geo & networks | A world map of traffic by country; the networks (AS) traffic came from and went to, over time in bits/s and packets/s; traffic by country and by network | | Threat intel | Hosts that talked to addresses on your threat lists, and how much they sent | | Flow records | How many flow records there were and when (a bar per interval), and the records themselves, newest first, page by page, with selectable columns | +| Offline analysis | Packet captures (pcap, pcapng) analysed apart from the live data ([more](#offline-analysis)) | | Interface check | Traffic of every interface over time (ingress and egress, bits/s and packets/s), and flow numbers next to the interface counters, worst first, with reasons | | Sources | Devices, sampling, loss, collectors, SNMP, the countries and networks database, the logo, and **Names** | @@ -734,6 +735,19 @@ The same overview in Chinese; every page is available in 13 languages: ![Overview in Chinese](docs/images/overview-zh.png) +### Offline analysis + +**Offline analysis** looks at packet captures from Wireshark or tcpdump with the same pages as the live data, without mixing them in: + +1. **Upload capture files…**: `.pcap` or `.pcapng`, not compressed. Up to 3 files, each at most 50 MB. The files are turned into flows in a database of their own (`/sandbox/`); the live data, its numbers and findings are not touched. +2. **Analyse**: every page (overview, Top 66, traffic details, findings, flow paths, map, flow records) now shows the capture files over their whole time. An orange bar names the files; **Back to live data** returns. Each file appears as a device, so the **Device** box shows one file at a time. +3. The detection rules run over the capture: scans, port scans and password guessing are listed under **Findings**. Rules that need a day of history (lateral movement, unusual uploads) do not apply to a capture. +4. **Delete** removes a file and its data; **Delete all** removes everything. + +The demo includes an example capture with an attack in it. + +![Offline analysis: capture files with their packets, flows and time](docs/images/sandbox.png) + ## 10. Terminal UI ``` @@ -889,7 +903,7 @@ The data directory holds everything: | `password` | login passwords (hashed) | | `inventory.txt` | names (**Sources → Names**) | | `logo.png` (or `.svg`, `.jpg`, `.webp`, `.gif`) | your logo (**Sources → Logo**), if you uploaded one | -| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/` | countries and networks databases you added or downloaded, and threat lists | +| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/`, `sandbox/` | countries and networks databases you added or downloaded, and threat lists | **How long data is kept**: flow detail 30 days, summaries (overview and long time ranges) 400 days. Older data is deleted automatically, checked every 5 diff --git a/cmd/traffic66/main.go b/cmd/traffic66/main.go index 882b417..9236e1f 100644 --- a/cmd/traffic66/main.go +++ b/cmd/traffic66/main.go @@ -4,6 +4,7 @@ package main import ( + "bytes" "context" "crypto/rand" "encoding/hex" @@ -32,6 +33,7 @@ import ( "github.com/githubflyideas/traffic66/internal/enrich" "github.com/githubflyideas/traffic66/internal/flow" "github.com/githubflyideas/traffic66/internal/pipeline" + "github.com/githubflyideas/traffic66/internal/sandbox" "github.com/githubflyideas/traffic66/internal/sim" "github.com/githubflyideas/traffic66/internal/snmp" "github.com/githubflyideas/traffic66/internal/store" @@ -387,6 +389,11 @@ func serve(args []string, demo bool) { srv := &api.Server{Store: st, Pipe: pipe, Col: col, Inv: inv, ASN: asn, Thr: thr, DNS: dns, Det: det, Static: web.FS(), Version: version, Demo: demo, Check: checker.Check, Exists: checker.Exists, LocalTok: tok, DataDir: f.data, Started: time.Now()} srv.SNMP = poller.Status + srv.SB = sandbox.New(filepath.Join(f.data, "sandbox"), inv, asn, thr) + defer srv.SB.Close() + if demo { + demoSample(f.data, srv.SB) + } srv.Capture = func() []api.CaptureInfo { var out []api.CaptureInfo for _, c := range caps { @@ -480,6 +487,25 @@ func prepareDemo(dir string) { write("threats/scanner.txt", scan) } +// demoSample puts the example capture into the sandbox once, so that offline +// analysis can be tried at once; deleting it keeps it deleted. +func demoSample(dir string, sb *sandbox.Sandbox) { + mark := filepath.Join(dir, "sandbox-sample") + if _, err := os.Stat(mark); err == nil { + return + } + var buf bytes.Buffer + if err := sandbox.Sample(&buf, time.Now().Add(-2*time.Hour).Truncate(time.Minute)); err != nil { + log.Printf("demo: example capture: %v", err) + return + } + if _, err := sb.Add(sandbox.SampleName, &buf, true); err != nil { + log.Printf("demo: example capture: %v", err) + return + } + os.WriteFile(mark, nil, 0o644) +} + func backfillDemo(pipe *pipeline.Pipeline, st *store.Store) { var n int64 st.DB.QueryRow(`SELECT (SELECT count(*) FROM hot) + (SELECT count(*) FROM segments)`).Scan(&n) diff --git a/docs/README.ar.md b/docs/README.ar.md index 69f74ed..b606783 100644 --- a/docs/README.ar.md +++ b/docs/README.ar.md @@ -663,6 +663,7 @@ curl -L https://www.spamhaus.org/drop/drop.txt -o /threats/spamh | الجغرافيا والشبكات | خريطة العالم للحركة حسب الدولة؛ الشبكات (AS) التي جاءت منها الحركة وذهبت إليها، عبر الزمن بوحدة bits/s وpackets/s؛ والحركة حسب الدولة وحسب الشبكة | | معلومات التهديدات | المضيفات التي تواصلت مع عناوين في قوائم تهديداتك، وكم أرسلت | | سجلات التدفق | كم سجل تدفق كان هناك ومتى (شريط لكل فترة)، والسجلات نفسها، الأحدث أولًا، صفحةً صفحة، مع أعمدة قابلة للاختيار | +| التحليل دون اتصال | تحليل ملفات التقاط الحزم (pcap وpcapng) بمعزل عن البيانات الحية ([المزيد](#التحليل-دون-اتصال)) | | مطابقة الواجهات | حركة كل واجهة عبر الزمن (الدخول والخروج، bits/s وpackets/s)، وأرقام التدفقات بجوار عدّادات الواجهات، الأسوأ أولًا، مع الأسباب | | المصادر | الأجهزة، وأخذ العينات، والفقد، والمستقبِلات، وSNMP، وقاعدة بيانات الدول والشبكات، والشعار، و**الأسماء** | @@ -747,6 +748,19 @@ curl -L https://www.spamhaus.org/drop/drop.txt -o /threats/spamh +### التحليل دون اتصال + +يعرض **التحليل دون اتصال** ملفات التقاط Wireshark أو tcpdump بالصفحات نفسها المستخدمة للبيانات الحية، دون خلطها بها: + +1. **رفع ملفات التقاط…**: ‎`.pcap` أو ‎`.pcapng` غير مضغوطة. حتى 3 ملفات، كل منها 50 MB كحد أقصى. تتحول الملفات إلى تدفقات في قاعدة بيانات خاصة بها (`/sandbox/`)، فلا تتأثر البيانات الحية وأرقامها واكتشافاتها. +2. **تحليل**: تعرض كل الصفحات (نظرة عامة، أعلى 66، تفاصيل الحركة، الاكتشافات، مسارات الحركة، الخريطة، سجلات التدفق) الملفات على امتداد وقتها كله. يذكر شريط برتقالي أسماء الملفات، و**العودة إلى البيانات الحية** يعيدك. يظهر كل ملف كجهاز، فيعرض مربع **الجهاز** ملفاً واحداً في كل مرة. +3. تعمل قواعد الكشف على الالتقاط: تظهر عمليات المسح ومسح المنافذ وتخمين كلمات المرور في **الاكتشافات**. القواعد التي تحتاج إلى يوم من السجل (الحركة الجانبية، الرفع غير المعتاد) لا تنطبق على الالتقاط. +4. **حذف** يحذف ملفاً وبياناته، و**حذف الكل** يحذف كل شيء. + +يتضمن العرض التجريبي ملف التقاط نموذجياً فيه هجوم. + +![التحليل دون اتصال: ملفات الالتقاط مع حزمها وتدفقاتها ووقتها](images/sandbox.png) + ## 10. الواجهة الطرفية ``` @@ -917,7 +931,7 @@ traffic66 -data /var/lib/traffic66 -listen "sflow=:6343,netflow=:2055,ipfix=:473 | `password` | كلمات مرور تسجيل الدخول (مُجزّأة) | | `inventory.txt` | الأسماء (**المصادر ← الأسماء**) | | `logo.png` (أو `.svg`، `.jpg`، `.webp`، `.gif`) | شعارك (**المصادر ← الشعار**)، إن كنت قد رفعته | -| `country.mmdb`، `asn.mmdb`، `both.mmdb`، `asn.tsv.gz`، `dbip-country.mmdb`، `dbip-asn.mmdb`، `threats/` | قواعد بيانات الدول والشبكات وقوائم التهديدات التي أضفتها | +| `country.mmdb`، `asn.mmdb`، `both.mmdb`، `asn.tsv.gz`، `dbip-country.mmdb`، `dbip-asn.mmdb`، `threats/`، `sandbox/` | قواعد بيانات الدول والشبكات وقوائم التهديدات التي أضفتها | **مدة الاحتفاظ بالبيانات**: تفاصيل التدفقات 30 يومًا، والملخصات (النظرة العامة والفترات الطويلة) 400 يوم. تُحذف البيانات الأقدم تلقائيًا، ويُفحص ذلك كل 5 دقائق؛ لا يُحذف شيء غير ذلك ولا يوجد حد آخر. غيّر مدة التفاصيل diff --git a/docs/README.bn.md b/docs/README.bn.md index 5961b96..673140c 100644 --- a/docs/README.bn.md +++ b/docs/README.bn.md @@ -670,6 +670,7 @@ application, দেশ, ডিভাইস — ক্লিক করা যা | ভূগোল ও নেটওয়ার্ক | দেশ অনুযায়ী ট্রাফিকের বিশ্ব মানচিত্র; যেসব নেটওয়ার্ক (AS) থেকে ট্রাফিক এসেছে ও যেখানে গেছে, সময়ের সাথে bits/s ও packets/s-এ; দেশ ও নেটওয়ার্ক অনুযায়ী ট্রাফিক | | হুমকির তথ্য | যেসব host আপনার threat list-এর address-এর সাথে কথা বলেছে, এবং কতটা পাঠিয়েছে | | ফ্লো রেকর্ড | কতগুলো flow record ছিল এবং কখন (প্রতি interval-এ একটি bar), আর record-গুলো নিজেই, নতুনগুলো আগে, পেজ ধরে ধরে, বেছে নেওয়া যায় এমন column সহ | +| অফলাইন বিশ্লেষণ | pcap, pcapng ক্যাপচার লাইভ ডেটা থেকে আলাদা করে বিশ্লেষণ ([আরও](#অফলাইন-বিশ্লেষণ)) | | ইন্টারফেস মিলানো | সময়ের সাথে প্রতিটি interface-এর ট্রাফিক (ingress ও egress, bits/s ও packets/s), আর interface counter-এর পাশে flow-এর সংখ্যা, সবচেয়ে খারাপগুলো আগে, কারণসহ | | উৎস | ডিভাইস, sampling, loss, collector, SNMP, দেশ ও নেটওয়ার্ক ডেটাবেস, লোগো, এবং **নাম** | @@ -755,6 +756,19 @@ packet পাঠায় যে দেখা যায় না। ডেম +### অফলাইন বিশ্লেষণ + +**অফলাইন বিশ্লেষণ** Wireshark বা tcpdump-এর ক্যাপচার লাইভ ডেটার মতো একই পেজে দেখায়, তবে মেশায় না: + +1. **ক্যাপচার ফাইল আপলোড করুন…**: `.pcap` বা `.pcapng`, সংকুচিত নয়। সর্বোচ্চ 3টি ফাইল, প্রতিটি 50 MB পর্যন্ত। ফাইলগুলো ফ্লো-তে রূপান্তরিত হয়ে আলাদা ডেটাবেসে (`/sandbox/`) যায়; লাইভ ডেটা, তার হিসাব ও ফলাফল বদলায় না। +2. **বিশ্লেষণ**: সব পেজ (সংক্ষেপ, Top 66, ট্রাফিকের বিস্তারিত, ফলাফল, ফ্লো পথ, মানচিত্র, ফ্লো রেকর্ড) ক্যাপচারের পুরো সময় দেখায়। কমলা ব্যানারে ফাইলের নাম থাকে; **লাইভ ডেটায় ফিরুন** দিয়ে ফিরুন। প্রতিটি ফাইল একটি ডিভাইস হিসেবে দেখায়, তাই **ডিভাইস** বক্সে একটি করে ফাইল দেখা যায়। +3. শনাক্তকরণের নিয়মগুলো ক্যাপচারেও চলে: স্ক্যান, পোর্ট স্ক্যান ও পাসওয়ার্ড অনুমান **সন্দেহজনক কার্যকলাপ**-এ আসে। যেসব নিয়মে এক দিনের ইতিহাস লাগে (ল্যাটারাল মুভমেন্ট, অস্বাভাবিক আপলোড) সেগুলো ক্যাপচারে প্রযোজ্য নয়। +4. **মুছুন** একটি ফাইল ও তার ডেটা মুছে দেয়; **সব মুছুন** সব মুছে দেয়। + +ডেমোতে আক্রমণসহ একটি উদাহরণ ক্যাপচার আছে। + +![অফলাইন বিশ্লেষণ: ক্যাপচার ফাইল, তাদের প্যাকেট, ফ্লো ও সময়](images/sandbox.png) + ## 10. Terminal UI ``` @@ -924,7 +938,7 @@ traffic66 -data /var/lib/traffic66 -listen "sflow=:6343,netflow=:2055,ipfix=:473 | `password` | login পাসওয়ার্ড (hashed) | | `inventory.txt` | নাম (**উৎস → নাম**) | | `logo.png` (বা `.svg`, `.jpg`, `.webp`, `.gif`) | আপনার লোগো (**উৎস → লোগো**), যদি আপলোড করে থাকেন | -| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/` | আপনার যোগ করা দেশ ও নেটওয়ার্ক database এবং threat list | +| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/`, `sandbox/` | আপনার যোগ করা দেশ ও নেটওয়ার্ক database এবং threat list | **ডেটা কতদিন রাখা হয়**: flow detail 30 দিন, summary (overview ও দীর্ঘ সময়সীমা) 400 দিন। এর চেয়ে পুরোনো ডেটা নিজে থেকেই মুছে যায়, প্রতি 5 মিনিটে যাচাই হয়; এ ছাড়া কিছু মোছা হয় না এবং অন্য কোনো সীমা নেই। detail-এর মেয়াদ diff --git a/docs/README.es.md b/docs/README.es.md index e7309f6..12ba861 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -686,6 +686,7 @@ Páginas: | Geografía y redes | Un mapa del mundo del tráfico por país; las redes (AS) de las que vino y a las que fue el tráfico, en el tiempo en bits/s y paquetes/s; tráfico por país y por red | | Inteligencia de amenazas | Hosts que hablaron con direcciones de sus listas de amenazas y cuánto enviaron | | Registros de flujo | Cuántos registros de flujo hubo y cuándo (una barra por intervalo), y los registros mismos, del más reciente al más antiguo, página a página, con columnas seleccionables | +| Análisis offline | Capturas de paquetes (pcap, pcapng) analizadas aparte de los datos en vivo ([más](#análisis-offline)) | | Verificación de interfaces | Tráfico de cada interfaz en el tiempo (entrada y salida, bits/s y paquetes/s), y cifras de flujo junto a los contadores de interfaz, de peor a mejor, con motivos | | Fuentes | Equipos, muestreo, pérdidas, colectores, SNMP, la base de datos de países y redes, el logotipo y **Nombres** | @@ -775,6 +776,19 @@ El mismo resumen en chino; todas las páginas están disponibles en 13 idiomas: +### Análisis offline + +**Análisis offline** muestra capturas de Wireshark o tcpdump con las mismas páginas que los datos en vivo, sin mezclarlas: + +1. **Subir archivos de captura…**: `.pcap` o `.pcapng`, sin comprimir. Hasta 3 archivos, cada uno de 50 MB como máximo. Los archivos se convierten en flujos en una base de datos propia (`/sandbox/`); los datos en vivo, sus cifras y hallazgos no se tocan. +2. **Analizar**: todas las páginas (resumen, Top 66, detalles del tráfico, hallazgos, rutas, mapa, registros de flujo) muestran los archivos durante todo su tiempo. Una barra naranja nombra los archivos; **Volver a los datos en vivo** vuelve. Cada archivo aparece como un dispositivo, así que el cuadro **Dispositivo** muestra un archivo cada vez. +3. Las reglas de detección se aplican a la captura: barridos, escaneos de puertos y adivinación de contraseñas aparecen en **Hallazgos**. Las reglas que necesitan un día de historial (movimiento lateral, subidas inusuales) no se aplican a una captura. +4. **Eliminar** borra un archivo y sus datos; **Eliminar todo** lo borra todo. + +La demo incluye una captura de ejemplo con un ataque. + +![Análisis offline: archivos de captura con sus paquetes, flujos y tiempo](images/sandbox.png) + ## 10. Interfaz de terminal ``` @@ -948,7 +962,7 @@ El directorio de datos lo contiene todo: | `password` | contraseñas de acceso (con hash) | | `inventory.txt` | nombres (**Fuentes → Nombres**) | | `logo.png` (o `.svg`, `.jpg`, `.webp`, `.gif`) | su logotipo (**Fuentes → Logotipo**), si subió uno | -| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/` | bases de datos de países y redes y listas de amenazas que haya añadido | +| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/`, `sandbox/` | bases de datos de países y redes y listas de amenazas que haya añadido | **Cuánto tiempo se guardan los datos**: el detalle de flujos 30 días; los resúmenes (vista general y periodos largos) 400 días. Lo más antiguo se borra automáticamente, con una comprobación cada 5 minutos; no se borra nada más ni hay diff --git a/docs/README.fr.md b/docs/README.fr.md index 72a235e..1e74798 100644 --- a/docs/README.fr.md +++ b/docs/README.fr.md @@ -704,6 +704,7 @@ Pages : | Géographie et réseaux | Une carte du monde du trafic par pays ; les réseaux (AS) d'où venait et où allait le trafic, dans le temps en bits/s et paquets/s ; trafic par pays et par réseau | | Menaces | Hôtes ayant communiqué avec des adresses de vos listes de menaces, et volume envoyé | | Enregistrements de flux | Combien d'enregistrements de flux il y a eu et quand (une barre par intervalle), et les enregistrements eux-mêmes, du plus récent au plus ancien, page par page, avec colonnes au choix | +| Analyse hors ligne | Captures de paquets (pcap, pcapng) analysées à part des données en direct ([plus](#analyse-hors-ligne)) | | Contrôle des interfaces | Trafic de chaque interface dans le temps (entrée et sortie, bits/s et paquets/s), et chiffres de flux à côté des compteurs d'interface, les pires en premier, avec les causes | | Sources | Équipements, échantillonnage, pertes, collecteurs, SNMP, la base de données pays et réseaux, le logo et **Noms** | @@ -800,6 +801,19 @@ La même vue d'ensemble en chinois ; toutes les pages sont disponibles en 13 lan +### Analyse hors ligne + +**Analyse hors ligne** affiche des captures Wireshark ou tcpdump avec les mêmes pages que les données en direct, sans les mélanger : + +1. **Importer des fichiers de capture…** : `.pcap` ou `.pcapng`, non compressés. Jusqu'à 3 fichiers de 50 Mo au plus chacun. Les fichiers sont transformés en flux dans une base à part (`/sandbox/`) ; les données en direct, leurs chiffres et détections ne sont pas touchés. +2. **Analyser** : toutes les pages (vue d'ensemble, Top 66, détails du trafic, détections, chemins, carte, enregistrements de flux) montrent les fichiers sur toute leur durée. Une barre orange nomme les fichiers ; **Retour aux données en direct** revient. Chaque fichier apparaît comme un équipement : le champ **Équipement** montre un fichier à la fois. +3. Les règles de détection s'appliquent à la capture : balayages, scans de ports et essais de mots de passe apparaissent dans **Détections**. Les règles qui demandent un jour d'historique (mouvement latéral, envois inhabituels) ne s'appliquent pas à une capture. +4. **Supprimer** efface un fichier et ses données ; **Tout supprimer** efface tout. + +La démo contient une capture d'exemple avec une attaque. + +![Analyse hors ligne : fichiers de capture avec leurs paquets, flux et période](images/sandbox.png) + ## 10. Interface terminal ``` @@ -976,7 +990,7 @@ Le répertoire de données contient tout : | `password` | mots de passe de connexion (hachés) | | `inventory.txt` | noms (**Sources → Noms**) | | `logo.png` (ou `.svg`, `.jpg`, `.webp`, `.gif`) | votre logo (**Sources → Logo**), si vous en avez importé un | -| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/` | bases de données pays et réseaux et listes de menaces que vous avez ajoutées | +| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/`, `sandbox/` | bases de données pays et réseaux et listes de menaces que vous avez ajoutées | **Durée de conservation** : le détail des flux 30 jours, les résumés (vue d'ensemble et longues périodes) 400 jours. Les données plus anciennes sont supprimées automatiquement, vérifiées toutes les 5 minutes ; rien d'autre n'est diff --git a/docs/README.hi.md b/docs/README.hi.md index 57cc00a..f6c679d 100644 --- a/docs/README.hi.md +++ b/docs/README.hi.md @@ -671,6 +671,7 @@ application, देश, डिवाइस — पर क्लिक किय | भूगोल और नेटवर्क | देश के अनुसार ट्रैफ़िक का विश्व मानचित्र; वे नेटवर्क (AS) जहाँ से ट्रैफ़िक आया और जहाँ गया, समय के साथ bits/s और packets/s में; देश के हिसाब से और नेटवर्क के हिसाब से ट्रैफ़िक | | ख़तरे की जानकारी | वे hosts जिन्होंने आपकी threat lists के addresses से बात की, और उन्होंने कितना भेजा | | फ़्लो रिकॉर्ड | कितने flow records थे और कब (हर interval के लिए एक bar), और ख़ुद records, सबसे नए पहले, पेज-दर-पेज, चुने जा सकने वाले columns के साथ | +| ऑफ़लाइन विश्लेषण | pcap, pcapng कैप्चर का लाइव डेटा से अलग विश्लेषण ([और](#ऑफ़लाइन-विश्लेषण)) | | इंटरफ़ेस मिलान | समय के साथ हर interface का ट्रैफ़िक (ingress और egress, bits/s और packets/s), और interface counters के बगल में flow के आँकड़े, सबसे ख़राब पहले, कारणों के साथ | | स्रोत | डिवाइस, sampling, loss, collectors, SNMP, देश और नेटवर्क डेटाबेस, लोगो, और **नाम** | @@ -758,6 +759,19 @@ internet scanner के। +### ऑफ़लाइन विश्लेषण + +**ऑफ़लाइन विश्लेषण** Wireshark या tcpdump के कैप्चर को लाइव डेटा वाले पेजों पर ही दिखाता है, बिना उन्हें मिलाए: + +1. **कैप्चर फ़ाइलें अपलोड करें…**: `.pcap` या `.pcapng`, संपीड़ित नहीं। अधिकतम 3 फ़ाइलें, हर एक 50 MB तक। फ़ाइलें फ़्लो में बदलकर अलग डेटाबेस (`/sandbox/`) में जाती हैं; लाइव डेटा, उसके आँकड़े और निष्कर्ष नहीं बदलते। +2. **विश्लेषण करें**: सभी पेज (अवलोकन, Top 66, ट्रैफ़िक विवरण, निष्कर्ष, फ़्लो पथ, मानचित्र, फ़्लो रिकॉर्ड) कैप्चर फ़ाइलों का पूरा समय दिखाते हैं। नारंगी पट्टी फ़ाइलों के नाम बताती है; **लाइव डेटा पर लौटें** से लौटें। हर फ़ाइल एक डिवाइस की तरह दिखती है, इसलिए **डिवाइस** बॉक्स से एक-एक फ़ाइल देखी जा सकती है। +3. पहचान के नियम कैप्चर पर भी चलते हैं: स्कैन, पोर्ट स्कैन और पासवर्ड अनुमान **संदिग्ध गतिविधि** में दिखते हैं। जिन नियमों को एक दिन का इतिहास चाहिए (लेटरल मूवमेंट, असामान्य अपलोड) वे कैप्चर पर लागू नहीं होते। +4. **हटाएँ** एक फ़ाइल और उसका डेटा हटाता है; **सब हटाएँ** सब कुछ हटाता है। + +डेमो में हमले वाला एक उदाहरण कैप्चर शामिल है। + +![ऑफ़लाइन विश्लेषण: कैप्चर फ़ाइलें, उनके पैकेट, फ़्लो और समय](images/sandbox.png) + ## 10. Terminal UI ``` @@ -927,7 +941,7 @@ traffic66 -data /var/lib/traffic66 -listen "sflow=:6343,netflow=:2055,ipfix=:473 | `password` | login पासवर्ड (hashed) | | `inventory.txt` | नाम (**स्रोत → नाम**) | | `logo.png` (या `.svg`, `.jpg`, `.webp`, `.gif`) | आपका लोगो (**स्रोत → लोगो**), अगर आपने अपलोड किया है | -| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/` | आपके जोड़े हुए देश और नेटवर्क databases और threat lists | +| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/`, `sandbox/` | आपके जोड़े हुए देश और नेटवर्क databases और threat lists | **डेटा कितने समय तक रखा जाता है**: flow detail 30 दिन, summaries (overview और लंबी समय-सीमाएँ) 400 दिन। इससे पुराना डेटा अपने आप हटता है, हर 5 मिनट में जाँच होती है; इसके अलावा कुछ नहीं हटता और कोई और सीमा नहीं है। diff --git a/docs/README.id.md b/docs/README.id.md index 0ba97d6..dacbb57 100644 --- a/docs/README.id.md +++ b/docs/README.id.md @@ -683,6 +683,7 @@ Halaman: | Geografi & jaringan | Peta dunia trafik per negara; jaringan (AS) asal dan tujuan trafik, dari waktu ke waktu dalam bit/s dan paket/s; trafik per negara dan per jaringan | | Intel ancaman | Host yang berkomunikasi dengan alamat di daftar ancaman Anda, dan berapa banyak yang mereka kirim | | Catatan flow | Berapa banyak catatan flow dan kapan (satu batang per interval), dan catatannya sendiri, terbaru di atas, per halaman, dengan kolom yang bisa dipilih | +| Analisis offline | Tangkapan paket (pcap, pcapng) dianalisis terpisah dari data langsung ([selengkapnya](#analisis-offline)) | | Pencocokan antarmuka | Trafik setiap interface dari waktu ke waktu (masuk dan keluar, bit/s dan paket/s), dan angka flow di samping counter interface, yang paling buruk di atas, beserta alasannya | | Sumber | Perangkat, sampling, loss, collector, SNMP, basis data negara dan jaringan, logo, dan **Nama** | @@ -777,6 +778,19 @@ Ringkasan yang sama dalam bahasa Tionghoa; setiap halaman tersedia dalam 13 baha +### Analisis offline + +**Analisis offline** menampilkan tangkapan Wireshark atau tcpdump dengan halaman yang sama seperti data langsung, tanpa mencampurnya: + +1. **Unggah file tangkapan…**: `.pcap` atau `.pcapng`, tidak dikompresi. Maksimal 3 file, masing-masing paling besar 50 MB. File diubah menjadi flow di basis data tersendiri (`/sandbox/`); data langsung, angka, dan temuannya tidak tersentuh. +2. **Analisis**: semua halaman (ringkasan, Top 66, detail trafik, temuan, jalur, peta, catatan flow) menampilkan file untuk seluruh waktunya. Bilah oranye menyebut nama file; **Kembali ke data langsung** kembali. Tiap file tampil sebagai perangkat, jadi kotak **Perangkat** menampilkan satu file sekaligus. +3. Aturan deteksi dijalankan pada tangkapan: pemindaian, pemindaian port, dan tebakan kata sandi muncul di **Temuan**. Aturan yang perlu riwayat sehari (pergerakan lateral, unggahan tak biasa) tidak berlaku untuk tangkapan. +4. **Hapus** menghapus satu file dan datanya; **Hapus semua** menghapus semuanya. + +Demo menyertakan contoh tangkapan berisi serangan. + +![Analisis offline: file tangkapan beserta paket, flow, dan waktunya](images/sandbox.png) + ## 10. Antarmuka terminal ``` @@ -948,7 +962,7 @@ Semua tersimpan di direktori data: | `password` | kata sandi login (dalam bentuk hash) | | `inventory.txt` | nama (**Sumber → Nama**) | | `logo.png` (atau `.svg`, `.jpg`, `.webp`, `.gif`) | logo Anda (**Sumber → Logo**), jika Anda mengunggahnya | -| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/` | basis data negara dan jaringan serta daftar ancaman yang Anda tambahkan | +| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/`, `sandbox/` | basis data negara dan jaringan serta daftar ancaman yang Anda tambahkan | **Berapa lama data disimpan**: detail flow 30 hari, ringkasan (ikhtisar dan rentang waktu panjang) 400 hari. Data yang lebih lama dihapus otomatis, diperiksa setiap 5 menit; tidak ada yang lain dihapus dan tidak ada batas lain. diff --git a/docs/README.ja.md b/docs/README.ja.md index 1f8d62d..63fa012 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -562,6 +562,7 @@ curl -L https://www.spamhaus.org/drop/drop.txt -o /threats/spamh | 地域とネットワーク | 国別トラフィックの世界地図、トラフィックの送信元・送信先のネットワーク(AS)の推移(bits/s と packets/s)、国別・ネットワーク別のトラフィック | | 脅威インテル | 脅威リスト上のアドレスと通信したホストと、その送信量 | | フローレコード | フローレコードの数とその時期(間隔ごとの棒グラフ)と、レコードそのもの(新しい順、ページ送り、表示列は選択可能) | +| オフライン分析 | pcap・pcapng のキャプチャをライブデータと分けて分析 ([詳しく](#オフライン分析)) | | インターフェース照合 | 全インターフェースのトラフィックの推移(受信と送信、bits/s と packets/s)と、フローの数値とインターフェースカウンターの対比(差の大きい順、理由付き) | | 受信状況 | 機器、サンプリング、ロス、コレクター、SNMP、国・ネットワークデータベース、ロゴ、**名前** | @@ -613,6 +614,19 @@ curl -L https://www.spamhaus.org/drop/drop.txt -o /threats/spamh +### オフライン分析 + +**オフライン分析** では Wireshark や tcpdump のキャプチャを、ライブデータと混ぜずに同じページで見られます: + +1. **キャプチャファイルをアップロード…**:`.pcap` または `.pcapng`(圧縮不可)。最大 3 ファイル、各 50 MB まで。ファイルはフローに変換されて専用のデータベース(`/sandbox/`)に入り、ライブデータや集計、検出結果には影響しません。 +2. **分析**:全ページ(概要、Top 66、トラフィック詳細、検出、フローパス、地図、フローレコード)がキャプチャの全期間を表示します。オレンジの帯にファイル名が出て、**ライブデータに戻る** で戻ります。各ファイルは 1 台の機器として扱われるので、**機器** 欄で 1 ファイルずつ見られます。 +3. 検出ルールもキャプチャに対して動き、スキャン、ポートスキャン、パスワード推測が **検知** に出ます。1 日分の履歴が必要なルール(横展開、異常なアップロード)はキャプチャには適用されません。 +4. **削除** でファイルとそのデータを、**すべて削除** ですべてを削除します。 + +デモには攻撃を含むサンプルキャプチャが入っています。 + +![オフライン分析:キャプチャファイルとパケット数、フロー数、時間](images/sandbox.png) + ## 10. ターミナル UI ``` @@ -759,7 +773,7 @@ traffic66 -data /var/lib/traffic66 -listen "sflow=:6343,netflow=:2055,ipfix=:473 | `password` | ログインパスワード(ハッシュ化) | | `inventory.txt` | 名前(**受信状況 → 名前**) | | `logo.png`(または `.svg`、`.jpg`、`.webp`、`.gif`) | アップロードしたロゴ(**受信状況 → ロゴ**)。アップロードした場合のみ | -| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/` | 追加した国・ネットワークデータベースと脅威リスト | +| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/`, `sandbox/` | 追加した国・ネットワークデータベースと脅威リスト | **データの保存期間**:フロー明細は 30 日、集計(概要と長い期間)は 400 日です。それより古いデータは自動で削除され、5 分ごとに確認します。それ以外は何も削除せず、ほかの制限もありません。明細の保存日数は `-retention-days` で変更でき、何日でも指定できます(例:`-retention-days 365`)。ディスク使用量もそれに応じて増えます。保存日数分が入りきらないときは、サイドメニューの **空き** が赤くなります。ディスクが満杯になると、空きができるまで新しいフローは保存できません。 diff --git a/docs/README.ko.md b/docs/README.ko.md index 04d5180..e817d9e 100644 --- a/docs/README.ko.md +++ b/docs/README.ko.md @@ -647,6 +647,7 @@ curl -L https://www.spamhaus.org/drop/drop.txt -o /threats/spamh | 지역 및 네트워크 | 국가별 트래픽 세계 지도, 트래픽이 오간 네트워크(AS)의 시간에 따른 변화(bits/s와 packets/s), 국가별, 네트워크별 트래픽 | | 위협 정보 | 위협 목록에 있는 주소와 통신한 호스트와 그 전송량 | | 플로 레코드 | 플로 레코드가 얼마나, 언제 있었는지(간격마다 막대 하나), 그리고 레코드 자체, 최신순, 페이지 단위, 표시할 열 선택 가능 | +| 오프라인 분석 | pcap·pcapng 캡처를 실시간 데이터와 따로 분석 ([자세히](#오프라인-분석)) | | 인터페이스 대조 | 모든 인터페이스의 시간에 따른 트래픽(수신과 송신, bits/s와 packets/s), 그리고 플로 수치와 인터페이스 카운터 비교, 차이가 큰 순, 원인 포함 | | 수집 상태 | 장비, 샘플링, 유실, 수집기, SNMP, 국가 및 네트워크 데이터베이스, 로고, **이름** | @@ -726,6 +727,19 @@ curl -L https://www.spamhaus.org/drop/drop.txt -o /threats/spamh +### 오프라인 분석 + +**오프라인 분석**는 Wireshark나 tcpdump 캡처를 실시간 데이터와 섞지 않고 같은 페이지로 보여 줍니다: + +1. **캡처 파일 업로드…**: `.pcap` 또는 `.pcapng`(압축 불가). 최대 3개 파일, 각각 50 MB 이하. 파일은 플로로 바뀌어 별도 데이터베이스(`/sandbox/`)에 들어가며, 실시간 데이터와 집계, 탐지 결과에는 영향이 없습니다. +2. **분석**: 모든 페이지(개요, Top 66, 트래픽 상세, 탐지, 플로 경로, 지도, 플로 레코드)가 캡처의 전체 기간을 보여 줍니다. 주황색 막대에 파일 이름이 나오고, **실시간 데이터로 돌아가기**로 돌아갑니다. 각 파일은 장비 하나로 나타나므로 **장비** 칸으로 파일을 하나씩 볼 수 있습니다. +3. 탐지 규칙도 캡처에 대해 실행되어 스캔, 포트 스캔, 비밀번호 추측이 **탐지**에 나옵니다. 하루치 이력이 필요한 규칙(내부 확산, 비정상 업로드)은 캡처에 적용되지 않습니다. +4. **삭제**는 파일과 데이터를, **모두 삭제**는 전부를 지웁니다. + +데모에는 공격이 담긴 예제 캡처가 들어 있습니다. + +![오프라인 분석: 캡처 파일과 패킷, 플로, 시간](images/sandbox.png) + ## 10. 터미널 UI ``` @@ -889,7 +903,7 @@ traffic66 -data /var/lib/traffic66 -listen "sflow=:6343,netflow=:2055,ipfix=:473 | `password` | 로그인 비밀번호(해시) | | `inventory.txt` | 이름(**수집 상태 → 이름**) | | `logo.png`(또는 `.svg`, `.jpg`, `.webp`, `.gif`) | 업로드한 경우 내 로고(**수집 상태 → 로고**) | -| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/` | 추가한 국가 및 네트워크 데이터베이스와 위협 목록 | +| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/`, `sandbox/` | 추가한 국가 및 네트워크 데이터베이스와 위협 목록 | **데이터 보존 기간**: 플로 상세는 30일, 요약(개요와 긴 기간)은 400일입니다. 그보다 오래된 데이터는 자동으로 삭제되며 5분마다 확인합니다. 그 밖에는 아무것도 삭제하지 않고 다른 제한도 없습니다. 상세 보존 일수는 `-retention-days`로 바꿀 수 있고 diff --git a/docs/README.pt.md b/docs/README.pt.md index 9dc4d35..cb38982 100644 --- a/docs/README.pt.md +++ b/docs/README.pt.md @@ -686,6 +686,7 @@ Páginas: | Geografia e redes | Um mapa-múndi do tráfego por país; as redes (AS) de onde o tráfego veio e para onde foi, ao longo do tempo em bits/s e pacotes/s; tráfego por país e por rede | | Ameaças | Hosts que se comunicaram com endereços das suas listas de ameaças, e quanto enviaram | | Registros de fluxo | Quantos registros de fluxo houve e quando (uma barra por intervalo), e os próprios registros, dos mais recentes para os mais antigos, página a página, com colunas selecionáveis | +| Análise offline | Capturas de pacotes (pcap, pcapng) analisadas separadas dos dados ao vivo ([mais](#análise-offline)) | | Conferência de interfaces | Tráfego de cada interface ao longo do tempo (entrada e saída, bits/s e pacotes/s), e números de fluxo ao lado dos contadores de interface, piores primeiro, com os motivos | | Fontes | Equipamentos, amostragem, perdas, coletores, SNMP, o banco de dados de países e redes, o logotipo e **Nomes** | @@ -776,6 +777,19 @@ A mesma visão geral em chinês; todas as páginas estão disponíveis em 13 idi +### Análise offline + +**Análise offline** mostra capturas do Wireshark ou tcpdump com as mesmas páginas dos dados ao vivo, sem misturá-las: + +1. **Enviar arquivos de captura…**: `.pcap` ou `.pcapng`, sem compressão. Até 3 arquivos, cada um com no máximo 50 MB. Os arquivos viram fluxos num banco próprio (`/sandbox/`); os dados ao vivo, seus números e detecções não são afetados. +2. **Analisar**: todas as páginas (visão geral, Top 66, detalhes do tráfego, detecções, caminhos, mapa, registros de fluxo) mostram os arquivos durante todo o seu período. Uma barra laranja nomeia os arquivos; **Voltar aos dados ao vivo** volta. Cada arquivo aparece como um dispositivo, então o campo **Equipamento** mostra um arquivo por vez. +3. As regras de detecção rodam sobre a captura: varreduras, varreduras de portas e tentativas de senha aparecem em **Detecções**. Regras que precisam de um dia de histórico (movimento lateral, envios incomuns) não se aplicam a uma captura. +4. **Excluir** apaga um arquivo e seus dados; **Excluir tudo** apaga tudo. + +A demo inclui uma captura de exemplo com um ataque. + +![Análise offline: arquivos de captura com pacotes, fluxos e período](images/sandbox.png) + ## 10. Interface de terminal ``` @@ -947,7 +961,7 @@ O diretório de dados guarda tudo: | `password` | senhas de login (em hash) | | `inventory.txt` | nomes (**Fontes → Nomes**) | | `logo.png` (ou `.svg`, `.jpg`, `.webp`, `.gif`) | seu logotipo (**Fontes → Logotipo**), se você enviou um | -| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/` | bancos de dados de países e redes e listas de ameaças que você adicionou | +| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/`, `sandbox/` | bancos de dados de países e redes e listas de ameaças que você adicionou | **Por quanto tempo os dados ficam**: o detalhe de fluxos 30 dias; os resumos (visão geral e períodos longos) 400 dias. O que for mais antigo é apagado automaticamente, com verificação a cada 5 minutos; nada mais é apagado e diff --git a/docs/README.ru.md b/docs/README.ru.md index c2bcd08..457b38f 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -679,6 +679,7 @@ curl -L https://www.spamhaus.org/drop/drop.txt -o /threats/spamh | География и сети | Карта мира с трафиком по странам; сети (AS), откуда трафик пришёл и куда ушёл, во времени в бит/с и пакетах/с; трафик по странам и по сетям | | Угрозы | Хосты, обращавшиеся к адресам из ваших списков угроз, и сколько они отправили | | Записи потоков | Сколько было записей потоков и когда (столбик на интервал), и сами записи, новые сверху, постранично, с выбором столбцов | +| Офлайн-анализ | Захваты пакетов (pcap, pcapng) анализируются отдельно от живых данных ([подробнее](#офлайн-анализ)) | | Сверка интерфейсов | Трафик каждого интерфейса во времени (вход и выход, бит/с и пакеты/с) и цифры по потокам рядом со счётчиками интерфейсов, худшие сверху, с причинами | | Источники | Устройства, сэмплирование, потери, коллекторы, SNMP, база стран и сетей, логотип и **Названия** | @@ -770,6 +771,19 @@ traffic66 учится на уже имеющейся истории. +### Офлайн-анализ + +**Офлайн-анализ** показывает захваты Wireshark или tcpdump на тех же страницах, что и живые данные, не смешивая их: + +1. **Загрузить файлы захвата…**: `.pcap` или `.pcapng`, без сжатия. До 3 файлов, каждый не больше 50 МБ. Файлы превращаются в потоки в отдельной базе (`/sandbox/`); живые данные, их статистика и находки не затрагиваются. +2. **Анализ**: все страницы (обзор, Top 66, детали трафика, находки, пути, карта, записи потоков) показывают файлы за всё их время. Оранжевая полоса называет файлы; **Вернуться к живым данным** возвращает обратно. Каждый файл выглядит как устройство, поэтому поле **Устройство** показывает по одному файлу. +3. Правила обнаружения работают и на захвате: сканирования, сканирования портов и подбор паролей появляются в **Обнаружения**. Правила, которым нужен день истории (перемещение внутри сети, необычные выгрузки), к захвату не применяются. +4. **Удалить** удаляет файл и его данные; **Удалить всё** удаляет всё. + +В демо есть пример захвата с атакой. + +![Офлайн-анализ: файлы захвата с пакетами, потоками и временем](images/sandbox.png) + ## 10. Терминальный интерфейс ``` @@ -941,7 +955,7 @@ traffic66 -data /var/lib/traffic66 -listen "sflow=:6343,netflow=:2055,ipfix=:473 | `password` | пароли для входа (в виде хешей) | | `inventory.txt` | названия (**Источники → Названия**) | | `logo.png` (или `.svg`, `.jpg`, `.webp`, `.gif`) | ваш логотип (**Источники → Логотип**), если вы его загрузили | -| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/` | добавленные вами базы стран и сетей и списки угроз | +| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/`, `sandbox/` | добавленные вами базы стран и сетей и списки угроз | **Сколько хранятся данные**: детализация потоков 30 дней, сводки (обзор и длинные периоды) 400 дней. Более старые данные удаляются автоматически, проверка каждые 5 минут; больше ничего не удаляется и других ограничений нет. diff --git a/docs/README.ur.md b/docs/README.ur.md index 92960b6..c12ae9e 100644 --- a/docs/README.ur.md +++ b/docs/README.ur.md @@ -672,6 +672,7 @@ application، ملک، ڈیوائس — پر کلک کیا جا سکتا ہے: | مقامات اور نیٹ ورک | ملک کے لحاظ سے ٹریفک کا دنیا کا نقشہ؛ وہ نیٹ ورکس (AS) جہاں سے ٹریفک آئی اور جہاں گئی، وقت کے ساتھ bits/s اور packets/s میں؛ ملک کے حساب سے اور نیٹ ورک کے حساب سے ٹریفک | | خطرے کی معلومات | وہ hosts جنہوں نے آپ کی threat lists کے addresses سے بات کی، اور کتنا بھیجا | | فلو ریکارڈ | کتنے flow records تھے اور کب (ہر interval کے لیے ایک bar)، اور خود records، نئے پہلے، صفحہ بہ صفحہ، منتخب کیے جا سکنے والے columns کے ساتھ | +| آف لائن تجزیہ | pcap، pcapng کیپچر کا لائیو ڈیٹا سے الگ تجزیہ ([مزید](#آف-لائن-تجزیہ)) | | انٹرفیس جانچ | وقت کے ساتھ ہر interface کی ٹریفک (ingress اور egress، bits/s اور packets/s)، اور interface counters کے ساتھ flow کے اعداد، بدترین پہلے، وجوہات کے ساتھ | | ذرائع | ڈیوائسز، sampling، loss، collectors، SNMP، ممالک اور نیٹ ورکس کا ڈیٹابیس، لوگو، اور **نام** | @@ -759,6 +760,19 @@ packets بھیجتا ہے کہ نظر نہیں آتا۔ ڈیمو کا حملہ +### آف لائن تجزیہ + +**آف لائن تجزیہ** Wireshark یا tcpdump کے کیپچر کو لائیو ڈیٹا والے صفحات پر ہی دکھاتا ہے، انہیں ملائے بغیر: + +1. **کیپچر فائلیں اپ لوڈ کریں…**: ‎`.pcap` یا ‎`.pcapng`، کمپریس شدہ نہیں۔ زیادہ سے زیادہ 3 فائلیں، ہر ایک 50 MB تک۔ فائلیں فلو میں بدل کر الگ ڈیٹابیس (`/sandbox/`) میں جاتی ہیں؛ لائیو ڈیٹا، اس کے اعداد اور نتائج متاثر نہیں ہوتے۔ +2. **تجزیہ کریں**: تمام صفحات (جائزہ، Top 66، ٹریفک کی تفصیل، نتائج، فلو راستے، نقشہ، فلو ریکارڈز) کیپچر فائلوں کا پورا وقت دکھاتے ہیں۔ نارنجی پٹی فائلوں کے نام بتاتی ہے؛ **لائیو ڈیٹا پر واپس** سے واپس جائیں۔ ہر فائل ایک ڈیوائس کی طرح دکھتی ہے، اس لیے **آلہ** خانے سے ایک ایک فائل دیکھی جا سکتی ہے۔ +3. شناخت کے قواعد کیپچر پر بھی چلتے ہیں: اسکین، پورٹ اسکین اور پاس ورڈ کا اندازہ **مشتبہ سرگرمی** میں آتے ہیں۔ جن قواعد کو ایک دن کی تاریخ چاہیے (لیٹرل موومنٹ، غیر معمولی اپ لوڈ) وہ کیپچر پر لاگو نہیں ہوتے۔ +4. **حذف کریں** ایک فائل اور اس کا ڈیٹا ہٹاتا ہے؛ **سب حذف کریں** سب کچھ ہٹاتا ہے۔ + +ڈیمو میں حملے والی ایک مثال کیپچر شامل ہے۔ + +![آف لائن تجزیہ: کیپچر فائلیں، ان کے پیکٹ، فلو اور وقت](images/sandbox.png) + ## 10. Terminal UI ``` @@ -927,7 +941,7 @@ traffic66 -data /var/lib/traffic66 -listen "sflow=:6343,netflow=:2055,ipfix=:473 | `password` | login پاس ورڈز (hashed) | | `inventory.txt` | نام (**ذرائع → نام**) | | `logo.png` (یا `.svg`، `.jpg`، `.webp`، `.gif`) | آپ کا لوگو (**ذرائع → لوگو**)، اگر آپ نے اپ لوڈ کیا ہو | -| `country.mmdb`، `asn.mmdb`، `both.mmdb`، `asn.tsv.gz`، `dbip-country.mmdb`، `dbip-asn.mmdb`، `threats/` | آپ کے شامل کردہ ممالک اور نیٹ ورکس کے databases اور threat lists | +| `country.mmdb`، `asn.mmdb`، `both.mmdb`، `asn.tsv.gz`، `dbip-country.mmdb`، `dbip-asn.mmdb`، `threats/`، `sandbox/` | آپ کے شامل کردہ ممالک اور نیٹ ورکس کے databases اور threat lists | **ڈیٹا کتنی دیر رکھا جاتا ہے**: flow detail 30 دن، summaries (overview اور لمبی مدتیں) 400 دن۔ اس سے پرانا ڈیٹا خود بخود حذف ہوتا ہے، ہر 5 منٹ میں جانچ ہوتی ہے؛ اس کے علاوہ کچھ حذف نہیں ہوتا اور کوئی اور حد نہیں۔ detail diff --git a/docs/README.zh.md b/docs/README.zh.md index f87b331..fe2cdb5 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -562,6 +562,7 @@ curl -L https://www.spamhaus.org/drop/drop.txt -o /threats/spamh | 地理与运营商 | 世界地图上按国家显示的流量;流量来自和去往哪些网络(AS),以 bits/s 和 packets/s 显示随时间的变化;按国家和按网络统计的流量 | | 威胁情报 | 与威胁情报列表中地址有通信的主机,以及它们发送了多少流量 | | 流记录 | 有多少条流记录、出现在什么时候(每个间隔一根柱),以及流记录本身,最新的在前,分页显示,可选择显示列 | +| 离线分析 | 在实时数据之外分析抓包文件(pcap、pcapng) ([详情](#离线分析)) | | 接口对账 | 每个接口的流量随时间变化(入向和出向,bits/s 和 packets/s),以及流统计与接口计数器并列对比,差异最大的在前,附原因 | | 接入 | 设备、采样、丢包、采集器、SNMP、国家和运营商数据库、Logo,以及 **名称** | @@ -613,6 +614,19 @@ curl -L https://www.spamhaus.org/drop/drop.txt -o /threats/spamh +### 离线分析 + +**离线分析** 用和实时数据相同的页面查看 Wireshark 或 tcpdump 的抓包文件,但不混进实时数据: + +1. **上传抓包文件…**:`.pcap` 或 `.pcapng`,不支持压缩包。最多 3 个文件,每个不超过 50 MB。文件被转换成流,存进单独的数据库(`/sandbox/`),实时数据、统计和发现都不受影响。 +2. **分析**:所有页面(概览、Top 66、流量明细、发现、流向、地图、流记录)都改为显示抓包文件的整个时间段。橙色横条列出文件名,点 **返回实时数据** 返回。每个文件显示为一台设备,用 **设备** 筛选框可以只看一个文件。 +3. 检测规则会在抓包上运行:扫描、端口扫描和密码爆破会列在 **发现** 里。需要一天历史的规则(横向移动、异常上传)不适用于抓包。 +4. **删除** 删除一个文件及其数据;**全部删除** 删除全部。 + +demo 自带一个包含攻击过程的示例抓包。 + +![离线分析:抓包文件及其包数、流数和时间](images/sandbox.png) + ## 10. 终端界面 ``` @@ -759,7 +773,7 @@ traffic66 -data /var/lib/traffic66 -listen "sflow=:6343,netflow=:2055,ipfix=:473 | `password` | 登录密码(哈希) | | `inventory.txt` | 名称(**接入 → 名称**) | | `logo.png`(或 `.svg`、`.jpg`、`.webp`、`.gif`) | 你的 Logo(**接入 → Logo**),如果上传过 | -| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/` | 你添加的国家和运营商数据库以及威胁情报列表 | +| `country.mmdb`, `asn.mmdb`, `both.mmdb`, `asn.tsv.gz`, `dbip-country.mmdb`, `dbip-asn.mmdb`, `threats/`, `sandbox/` | 你添加的国家和运营商数据库以及威胁情报列表 | **数据保留多久**:流明细 30 天,汇总(总览和长时间范围)400 天。更早的数据自动删除,每 5 分钟检查一次;除此之外不删任何数据,也没有其他限制。用 `-retention-days` 修改明细保留天数,任意天数都可以,例如 `-retention-days 365`。占用的磁盘随之增长:保留天数放不下时,侧边栏的 **可用** 会变红。磁盘写满后,新的流无法保存,直到腾出空间。 diff --git a/docs/images/sandbox.png b/docs/images/sandbox.png new file mode 100644 index 0000000..730ecfe Binary files /dev/null and b/docs/images/sandbox.png differ diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go new file mode 100644 index 0000000..02009ba --- /dev/null +++ b/internal/api/sandbox.go @@ -0,0 +1,80 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/githubflyideas/traffic66/internal/sandbox" +) + +// data serves a page's data from the live database, or with ds=sb from the +// sandbox, where uploaded capture files are analysed apart from the live +// data. +func (s *Server) data(h func(*Server, http.ResponseWriter, *http.Request)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("ds") != "sb" { + h(s, w, r) + return + } + if s.SB == nil { + fail(w, errors.New("offline analysis is not available")) + return + } + st, det, inv := s.SB.Store() + if st == nil { + fail(w, errors.New("no capture files imported yet")) + return + } + c := &Server{Store: st, Det: det, Inv: inv, ASN: s.ASN, Thr: s.Thr, DNS: s.DNS, Static: s.Static, Version: s.Version, + Demo: s.Demo, DataDir: s.DataDir, Pipe: s.Pipe, Col: s.Col, SB: s.SB} + h(c, w, r) + } +} + +func (s *Server) getSandbox(w http.ResponseWriter, r *http.Request) { + if s.SB == nil { + fail(w, errors.New("offline analysis is not available")) + return + } + writeJSON(w, http.StatusOK, s.SB.Info()) +} + +// putSandboxFile receives one capture file (the request body) and queues it +// for import. Size and count limits are checked while it streams to disk. +func (s *Server) putSandboxFile(w http.ResponseWriter, r *http.Request) { + if s.SB == nil { + fail(w, errors.New("offline analysis is not available")) + return + } + if r.ContentLength > sandbox.MaxFileSize { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "too_big", "max": sandbox.MaxFileSize}) + return + } + f, err := s.SB.Add(r.URL.Query().Get("name"), r.Body, false) + if errors.Is(err, sandbox.ErrLimit) { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": err.Error(), "max": sandbox.MaxFileSize, "max_files": sandbox.MaxFiles}) + return + } + if errors.Is(err, sandbox.ErrNotCapture) { + writeJSON(w, http.StatusUnsupportedMediaType, map[string]any{"error": err.Error(), "code": "not_capture"}) + return + } + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"file": f, "sandbox": s.SB.Info()}) +} + +// deleteSandbox deletes one file (?name=) or all of them, and the data. +func (s *Server) deleteSandbox(w http.ResponseWriter, r *http.Request) { + if s.SB == nil { + fail(w, errors.New("offline analysis is not available")) + return + } + if err := s.SB.Delete(r.URL.Query().Get("name")); err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, s.SB.Info()) +} diff --git a/internal/api/sandbox_test.go b/internal/api/sandbox_test.go new file mode 100644 index 0000000..1a17bba --- /dev/null +++ b/internal/api/sandbox_test.go @@ -0,0 +1,68 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/githubflyideas/traffic66/internal/enrich" + "github.com/githubflyideas/traffic66/internal/sandbox" + "github.com/githubflyideas/traffic66/internal/store" +) + +// ds=sb answers from the sandbox; without it the live database answers and +// has none of the capture's data. +func TestSandboxData(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(store.Options{Dir: filepath.Join(dir, "live")}) + if err != nil { + t.Fatal(err) + } + defer st.Close() + sb := sandbox.New(filepath.Join(dir, "sandbox"), enrich.NewInventory(), enrich.NewASNDB(), enrich.NewThreats()) + defer sb.Close() + s := &Server{Store: st, SB: sb, Inv: enrich.NewInventory(), ASN: enrich.NewASNDB(), Thr: enrich.NewThreats()} + h := s.data((*Server).findings) + end := time.Now().Add(-3 * time.Hour).Truncate(time.Minute) + q := fmt.Sprintf("from=%d&to=%d", end.Add(-time.Hour).UnixMilli(), end.Add(time.Hour).UnixMilli()) + w := httptest.NewRecorder() + h(w, httptest.NewRequest("GET", "/api/findings?ds=sb&"+q, nil)) + if w.Code != 400 { + t.Fatalf("empty sandbox: %d %s", w.Code, w.Body) + } + var buf bytes.Buffer + sandbox.Sample(&buf, end) + w = httptest.NewRecorder() + s.putSandboxFile(w, httptest.NewRequest("POST", "/api/sandbox/files?name=a.pcap", &buf)) + if w.Code != 200 { + t.Fatalf("upload: %d %s", w.Code, w.Body) + } + for i := 0; i < 300 && !sb.Info().Ready; i++ { + time.Sleep(100 * time.Millisecond) + } + for sb.Info().Busy { + time.Sleep(100 * time.Millisecond) + } + count := func(ds string) int { + w := httptest.NewRecorder() + h(w, httptest.NewRequest("GET", "/api/findings?"+ds+q, nil)) + var out struct{ Findings []any } + json.Unmarshal(w.Body.Bytes(), &out) + return len(out.Findings) + } + if n := count("ds=sb&"); n != 3 { + t.Errorf("sandbox findings: %d", n) + } + if n := count(""); n != 0 { + t.Errorf("live findings: %d", n) + } + w = httptest.NewRecorder() + s.putSandboxFile(w, httptest.NewRequest("POST", "/api/sandbox/files?name=b.txt", bytes.NewReader([]byte("hello there")))) + if w.Code != 415 { + t.Errorf("text file: %d", w.Code) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index cdc4416..0c33375 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -21,6 +21,7 @@ import ( "github.com/githubflyideas/traffic66/internal/dnsres" "github.com/githubflyideas/traffic66/internal/enrich" "github.com/githubflyideas/traffic66/internal/pipeline" + "github.com/githubflyideas/traffic66/internal/sandbox" "github.com/githubflyideas/traffic66/internal/store" ) @@ -34,6 +35,7 @@ type Server struct { Thr *enrich.Threats DNS *dnsres.Resolver Det *detect.Detector + SB *sandbox.Sandbox // offline analysis of capture files Static fs.FS Version string Demo bool @@ -82,20 +84,23 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /logo", s.logo) api := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, s.auth(h)) } api("GET /api/status", s.status) - api("GET /api/overview", s.overview) - api("GET /api/topn", s.topn) - api("GET /api/sankey", s.sankey) - api("GET /api/series", s.series) - api("GET /api/records", s.records) - api("GET /api/threats", s.threats) + api("GET /api/overview", s.data((*Server).overview)) + api("GET /api/topn", s.data((*Server).topn)) + api("GET /api/sankey", s.data((*Server).sankey)) + api("GET /api/series", s.data((*Server).series)) + api("GET /api/records", s.data((*Server).records)) + api("GET /api/threats", s.data((*Server).threats)) api("GET /api/ifaces", s.ifaces) api("GET /api/recon", s.recon) api("GET /api/sources", s.sources) api("POST /api/resolve", s.resolve) api("GET /api/inventory", s.getInventory) api("POST /api/inventory", s.putInventory) - api("GET /api/findings", s.findings) - api("POST /api/findings", s.setFindings) + api("GET /api/findings", s.data((*Server).findings)) + api("POST /api/findings", s.data((*Server).setFindings)) + api("GET /api/sandbox", s.getSandbox) + api("POST /api/sandbox/files", s.putSandboxFile) + api("DELETE /api/sandbox/files", s.deleteSandbox) api("POST /api/logo", s.putLogo) api("DELETE /api/logo", s.deleteLogo) api("GET /api/geo", s.getGeo) diff --git a/internal/pcapfile/pcapfile.go b/internal/pcapfile/pcapfile.go new file mode 100644 index 0000000..f0154bc --- /dev/null +++ b/internal/pcapfile/pcapfile.go @@ -0,0 +1,282 @@ +// Package pcapfile reads packet capture files: classic pcap (microsecond and +// nanosecond, either byte order) and pcapng, optionally gzip-compressed, as +// written by tcpdump, Wireshark and dumpcap. +package pcapfile + +import ( + "bufio" + "encoding/binary" + "errors" + "fmt" + "io" + "math" + "time" +) + +// Link types handled by the flow builder. +const ( + LinkNull = 0 + LinkEthernet = 1 + LinkRaw = 101 + LinkLoop = 108 + LinkSLL = 113 + LinkIPv4 = 228 + LinkIPv6 = 229 + LinkSLL2 = 276 +) + +// Packet is one captured packet. Data is valid until the next call to Next. +type Packet struct { + Time time.Time + Data []byte // captured bytes (may be shorter than the packet) + WireLen int // length of the packet on the wire + Link int // link type of the interface it was captured on +} + +// ErrFormat is returned for files that are neither pcap nor pcapng. +var ErrFormat = errors.New("not a pcap or pcapng file") + +// maxPacket bounds one packet record; larger lengths mean a damaged file. +const maxPacket = 1 << 18 + +// Reader reads packets from a capture file. +type Reader struct { + r *bufio.Reader + ng bool + bo binary.ByteOrder + buf []byte + Count uint64 + + // pcap + link int + nanos bool + + // pcapng + ifaces []iface + lastTS time.Time +} + +type iface struct { + link int + res float64 // seconds per timestamp unit +} + +// Format names the format of a file from its first bytes ("pcap", +// "pcapng") or returns "". +func Format(b []byte) string { + if len(b) < 4 { + return "" + } + switch binary.LittleEndian.Uint32(b) { + case 0xa1b2c3d4, 0xd4c3b2a1, 0xa1b23c4d, 0x4d3cb2a1: + return "pcap" + case 0x0a0d0d0a: + return "pcapng" + } + return "" +} + +// NewReader reads the file header from r. +func NewReader(r io.Reader) (*Reader, error) { + br := bufio.NewReaderSize(r, 1<<16) + head, err := br.Peek(4) + if err != nil { + return nil, ErrFormat + } + rd := &Reader{r: br, buf: make([]byte, 0, 1<<16)} + switch Format(head) { + case "pcap": + return rd, rd.pcapHeader() + case "pcapng": + rd.ng = true + return rd, nil + } + return nil, ErrFormat +} + +func (rd *Reader) read(n int) ([]byte, error) { + if cap(rd.buf) < n { + rd.buf = make([]byte, n) + } + b := rd.buf[:n] + _, err := io.ReadFull(rd.r, b) + return b, err +} + +func (rd *Reader) pcapHeader() error { + h, err := rd.read(24) + if err != nil { + return ErrFormat + } + switch binary.LittleEndian.Uint32(h) { + case 0xa1b2c3d4: + rd.bo = binary.LittleEndian + case 0xa1b23c4d: + rd.bo, rd.nanos = binary.LittleEndian, true + case 0xd4c3b2a1: + rd.bo = binary.BigEndian + case 0x4d3cb2a1: + rd.bo, rd.nanos = binary.BigEndian, true + } + // the low 16 bits are the link type; the high bits may carry an FCS flag + rd.link = int(rd.bo.Uint32(h[20:]) & 0xffff) + return nil +} + +// Next returns the next packet, or io.EOF at the end of the file. +func (rd *Reader) Next(p *Packet) error { + if rd.ng { + return rd.nextNG(p) + } + h, err := rd.read(16) + if err != nil { + if err == io.ErrUnexpectedEOF { + return io.EOF // a capture cut off mid-header + } + return err + } + sec, frac := rd.bo.Uint32(h), rd.bo.Uint32(h[4:]) + incl, orig := rd.bo.Uint32(h[8:]), rd.bo.Uint32(h[12:]) + if incl > maxPacket { + return fmt.Errorf("packet %d: damaged record (%d bytes)", rd.Count+1, incl) + } + ns := int64(frac) * 1000 + if rd.nanos { + ns = int64(frac) + } + data, err := rd.read(int(incl)) + if err != nil { + return io.EOF + } + rd.Count++ + *p = Packet{Time: time.Unix(int64(sec), ns).UTC(), Data: data, WireLen: int(max(orig, incl)), Link: rd.link} + return nil +} + +func (rd *Reader) nextNG(p *Packet) error { + for { + h, err := rd.read(8) + if err != nil { + if err == io.ErrUnexpectedEOF { + return io.EOF + } + return err + } + typ := binary.LittleEndian.Uint32(h) + if typ == 0x0a0d0d0a { // section header: learn the byte order + if err := rd.section(h); err != nil { + return err + } + continue + } + if rd.bo == nil { + return ErrFormat + } + typ = rd.bo.Uint32(h) + blen := rd.bo.Uint32(h[4:]) + if blen < 12 || blen > maxPacket+64 || blen%4 != 0 { + return fmt.Errorf("block after packet %d: damaged (%d bytes)", rd.Count, blen) + } + body, err := rd.read(int(blen - 8)) + if err != nil { + return io.EOF + } + body = body[:len(body)-4] // trailing length + switch typ { + case 1: // interface description + if len(body) < 8 { + continue + } + ifc := iface{link: int(rd.bo.Uint16(body)), res: 1e-6} + rd.options(body[8:], func(code uint16, v []byte) { + if code == 9 && len(v) >= 1 { // if_tsresol + if v[0]&0x80 != 0 { + ifc.res = math.Pow(2, -float64(v[0]&0x7f)) + } else { + ifc.res = math.Pow(10, -float64(v[0])) + } + } + }) + rd.ifaces = append(rd.ifaces, ifc) + case 6, 2: // enhanced packet, obsolete packet + if len(body) < 20 { + continue + } + id := rd.bo.Uint32(body) + if typ == 2 { + id = uint32(rd.bo.Uint16(body)) + } + ts := uint64(rd.bo.Uint32(body[4:]))<<32 | uint64(rd.bo.Uint32(body[8:])) + incl, orig := rd.bo.Uint32(body[12:]), rd.bo.Uint32(body[16:]) + if int(id) >= len(rd.ifaces) || 20+int(incl) > len(body) { + continue + } + ifc := rd.ifaces[id] + t := tsTime(ts, ifc.res) + rd.lastTS = t + rd.Count++ + *p = Packet{Time: t, Data: body[20 : 20+incl], WireLen: int(max(orig, incl)), Link: ifc.link} + return nil + case 3: // simple packet: no timestamp, interface 0 + if len(body) < 4 || len(rd.ifaces) == 0 { + continue + } + orig := rd.bo.Uint32(body) + data := body[4:] + if int(orig) < len(data) { + data = data[:orig] + } + rd.Count++ + *p = Packet{Time: rd.lastTS, Data: data, WireLen: int(max(orig, uint32(len(data)))), Link: rd.ifaces[0].link} + return nil + } + } +} + +func (rd *Reader) section(h []byte) error { + blen := h[4:8] + bom, err := rd.r.Peek(4) + if err != nil { + return ErrFormat + } + switch binary.LittleEndian.Uint32(bom) { + case 0x1a2b3c4d: + rd.bo = binary.LittleEndian + case 0x4d3c2b1a: + rd.bo = binary.BigEndian + default: + return ErrFormat + } + n := rd.bo.Uint32(blen) + if n < 28 || n > 1<<20 { + return ErrFormat + } + if _, err := rd.read(int(n - 8)); err != nil { + return ErrFormat + } + rd.ifaces = nil // interfaces are per section + return nil +} + +func (rd *Reader) options(b []byte, fn func(code uint16, v []byte)) { + for len(b) >= 4 { + code, n := rd.bo.Uint16(b), int(rd.bo.Uint16(b[2:])) + if code == 0 || 4+n > len(b) { + return + } + fn(code, b[4:4+n]) + b = b[4+(n+3)&^3:] + } +} + +func tsTime(ts uint64, res float64) time.Time { + if res == 1e-6 { + return time.UnixMicro(int64(ts)).UTC() + } + if res == 1e-9 { + return time.Unix(0, int64(ts)).UTC() + } + sec := float64(ts) * res + s := math.Floor(sec) + return time.Unix(int64(s), int64((sec-s)*1e9)).UTC() +} diff --git a/internal/pcapfile/pcapfile_test.go b/internal/pcapfile/pcapfile_test.go new file mode 100644 index 0000000..00ff26f --- /dev/null +++ b/internal/pcapfile/pcapfile_test.go @@ -0,0 +1,111 @@ +package pcapfile + +import ( + "bytes" + "encoding/binary" + "io" + "testing" + "time" +) + +func readAll(t *testing.T, b []byte) []Packet { + t.Helper() + rd, err := NewReader(bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } + var out []Packet + for { + var p Packet + err := rd.Next(&p) + if err == io.EOF { + return out + } + if err != nil { + t.Fatal(err) + } + p.Data = append([]byte(nil), p.Data...) + out = append(out, p) + } +} + +func TestPcapRoundTrip(t *testing.T) { + var buf bytes.Buffer + w, _ := NewWriter(&buf, LinkEthernet) + t0 := time.Date(2026, 9, 1, 10, 0, 0, 123456000, time.UTC) + w.Write(t0, []byte{1, 2, 3}, 60) + w.Write(t0.Add(time.Second), []byte{4, 5}, 2) + for _, b := range [][]byte{buf.Bytes()} { + ps := readAll(t, b) + if len(ps) != 2 || !ps[0].Time.Equal(t0) || ps[0].WireLen != 60 || ps[1].Link != LinkEthernet || !bytes.Equal(ps[1].Data, []byte{4, 5}) { + t.Fatalf("%+v", ps) + } + } + // cut off in the middle of a packet: the complete ones are kept + if ps := readAll(t, buf.Bytes()[:buf.Len()-1]); len(ps) != 1 { + t.Fatalf("truncated: %d packets", len(ps)) + } +} + +// pcapng written by hand: section header, two interfaces (the second with +// nanosecond resolution), enhanced and simple packet blocks, an unknown block. +func TestPcapng(t *testing.T) { + le := binary.LittleEndian + var b bytes.Buffer + block := func(typ uint32, body []byte) { + for len(body)%4 != 0 { + body = append(body, 0) + } + n := uint32(12 + len(body)) + b.Write(le.AppendUint32(le.AppendUint32(nil, typ), n)) + b.Write(body) + b.Write(le.AppendUint32(nil, n)) + } + shb := le.AppendUint32(nil, 0x1a2b3c4d) + shb = append(shb, 1, 0, 0, 0) + shb = append(shb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff) + block(0x0a0d0d0a, shb) + idb := func(link uint16, opts []byte) []byte { + x := le.AppendUint16(nil, link) + x = append(x, 0, 0) + x = le.AppendUint32(x, 0) + return append(x, opts...) + } + block(1, idb(LinkEthernet, nil)) + block(1, idb(LinkRaw, []byte{9, 0, 1, 0, 9, 0, 0, 0, 0, 0, 0, 0})) // if_tsresol=9, end + epb := func(id uint32, ts uint64, data []byte, orig uint32) []byte { + x := le.AppendUint32(nil, id) + x = le.AppendUint32(x, uint32(ts>>32)) + x = le.AppendUint32(x, uint32(ts)) + x = le.AppendUint32(x, uint32(len(data))) + x = le.AppendUint32(x, orig) + return append(x, data...) + } + t0 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC) + block(6, epb(0, uint64(t0.UnixMicro()), []byte{1, 2, 3}, 100)) + block(99, []byte{1, 2, 3, 4}) + block(6, epb(1, uint64(t0.Add(time.Second+7).UnixNano()), []byte{0x45, 0}, 2)) + block(3, append(le.AppendUint32(nil, 5), 9, 9, 9, 9, 9, 9, 9)) + ps := readAll(t, b.Bytes()) + if len(ps) != 3 { + t.Fatalf("%d packets", len(ps)) + } + if !ps[0].Time.Equal(t0) || ps[0].WireLen != 100 || ps[0].Link != LinkEthernet { + t.Errorf("first %+v", ps[0]) + } + if !ps[1].Time.Equal(t0.Add(time.Second+7)) || ps[1].Link != LinkRaw { + t.Errorf("second %+v", ps[1]) + } + if len(ps[2].Data) != 5 || !ps[2].Time.Equal(ps[1].Time) { + t.Errorf("simple %+v", ps[2]) + } +} + +func TestNotACapture(t *testing.T) { + gz := []byte{0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3} + for _, b := range [][]byte{[]byte("hello world, this is text"), gz, []byte("ustar\x00")} { + if _, err := NewReader(bytes.NewReader(b)); err != ErrFormat { + t.Fatalf("%q: %v", b, err) + } + } +} diff --git a/internal/pcapfile/write.go b/internal/pcapfile/write.go new file mode 100644 index 0000000..7b1e22d --- /dev/null +++ b/internal/pcapfile/write.go @@ -0,0 +1,40 @@ +package pcapfile + +import ( + "encoding/binary" + "io" + "time" +) + +// Writer writes a classic pcap file (microsecond timestamps, little endian). +type Writer struct { + w io.Writer + hdr [16]byte +} + +// NewWriter writes the file header for link type link. +func NewWriter(w io.Writer, link int) (*Writer, error) { + var h [24]byte + binary.LittleEndian.PutUint32(h[0:], 0xa1b2c3d4) + binary.LittleEndian.PutUint16(h[4:], 2) + binary.LittleEndian.PutUint16(h[6:], 4) + binary.LittleEndian.PutUint32(h[16:], 65535) + binary.LittleEndian.PutUint32(h[20:], uint32(link)) + _, err := w.Write(h[:]) + return &Writer{w: w}, err +} + +// Write writes one packet of wireLen bytes on the wire, of which data was +// captured. +func (w *Writer) Write(t time.Time, data []byte, wireLen int) error { + us := t.UnixMicro() + binary.LittleEndian.PutUint32(w.hdr[0:], uint32(us/1e6)) + binary.LittleEndian.PutUint32(w.hdr[4:], uint32(us%1e6)) + binary.LittleEndian.PutUint32(w.hdr[8:], uint32(len(data))) + binary.LittleEndian.PutUint32(w.hdr[12:], uint32(max(wireLen, len(data)))) + if _, err := w.w.Write(w.hdr[:]); err != nil { + return err + } + _, err := w.w.Write(data) + return err +} diff --git a/internal/sandbox/flows.go b/internal/sandbox/flows.go new file mode 100644 index 0000000..4e3fbf9 --- /dev/null +++ b/internal/sandbox/flows.go @@ -0,0 +1,134 @@ +package sandbox + +import ( + "encoding/binary" + "net/netip" + "time" + + "github.com/githubflyideas/traffic66/internal/flow" + "github.com/githubflyideas/traffic66/internal/packet" + "github.com/githubflyideas/traffic66/internal/pcapfile" +) + +// builder turns packets into flow records the way local capture does +// (60 s active, 15 s idle timeout), but on the packets' own timestamps. +type builder struct { + exporter netip.Addr + domain uint32 + table map[fkey]*fentry + swept time.Time + emit func([]flow.Record) + skipped uint64 // packets without an IP header +} + +type fkey struct { + src, dst netip.Addr + sport, dport uint16 + proto uint8 + vlan uint16 + encap flow.Encap + l2 bool +} + +type fentry struct { + first, last time.Time + pkts, bytes uint64 + flags uint8 +} + +const ( + activeTimeout = 60 * time.Second + idleTimeout = 15 * time.Second + maxEntries = 500000 +) + +func newBuilder(exporter netip.Addr, domain uint32, emit func([]flow.Record)) *builder { + return &builder{exporter: exporter, domain: domain, table: map[fkey]*fentry{}, emit: emit} +} + +// decode reads the headers of a packet captured on link type link. +func decode(p *pcapfile.Packet, r *flow.Record) (l2 bool, ok bool) { + b := p.Data + switch p.Link { + case pcapfile.LinkEthernet: + return true, packet.Ethernet(b, r) + case pcapfile.LinkRaw, pcapfile.LinkIPv4, pcapfile.LinkIPv6, 12, 14: + return false, packet.IP(b, r) + case pcapfile.LinkNull, pcapfile.LinkLoop: + if len(b) < 4 { + return false, false + } + return false, packet.IP(b[4:], r) + case pcapfile.LinkSLL: + if len(b) < 16 { + return false, false + } + return false, ipType(binary.BigEndian.Uint16(b[14:])) && packet.IP(b[16:], r) + case pcapfile.LinkSLL2: + if len(b) < 20 { + return false, false + } + return false, ipType(binary.BigEndian.Uint16(b)) && packet.IP(b[20:], r) + } + return false, false +} + +func ipType(t uint16) bool { return t == 0x0800 || t == 0x86dd } + +func (b *builder) add(p *pcapfile.Packet) { + var r flow.Record + l2, ok := decode(p, &r) + if !ok { + b.skipped++ + return + } + k := fkey{r.Src, r.Dst, r.SrcPort, r.DstPort, r.Proto, r.VLAN, r.Encap, l2} + e := b.table[k] + if e == nil { + if len(b.table) >= maxEntries { + b.sweep(p.Time, true) + } + e = &fentry{first: p.Time} + b.table[k] = e + } + if p.Time.After(e.last) { + e.last = p.Time + } + e.pkts++ + n := uint64(p.WireLen) + if l2 { + n += 4 // captured frames exclude the FCS + } + e.bytes += n + e.flags |= r.TCPFlags + if p.Time.Sub(b.swept) >= time.Second { + b.sweep(p.Time, false) + } +} + +// sweep emits flows that ended (idle) or ran long enough (active); all +// emits everything. +func (b *builder) sweep(now time.Time, all bool) { + b.swept = now + var out []flow.Record + for k, e := range b.table { + idle := now.Sub(e.last) >= idleTimeout + active := now.Sub(e.first) >= activeTimeout + if !all && !idle && !active { + 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, + 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} + } + } + if len(out) > 0 { + b.emit(out) + } +} diff --git a/internal/sandbox/sample.go b/internal/sandbox/sample.go new file mode 100644 index 0000000..eae1e35 --- /dev/null +++ b/internal/sandbox/sample.go @@ -0,0 +1,135 @@ +package sandbox + +import ( + "bufio" + "encoding/binary" + "io" + "math/rand/v2" + "net/netip" + "sort" + "time" + + "github.com/githubflyideas/traffic66/internal/pcapfile" +) + +// SampleName is the file name of the demo's example capture. +const SampleName = "example-office-attack.pcap" + +// Sample writes the demo's example capture: 15 minutes of an office network +// ending at end, with an attack inside it. A compromised PC (10.10.3.27) +// scans every low port of the file server, sweeps the office for SMB, then +// guesses RDP passwords on the file server. Only headers are captured (snap length 54), +// so the file stays small while the traffic sizes are real. +func Sample(w io.Writer, end time.Time) error { + rng := rand.New(rand.NewPCG(66, 6)) + start := end.Add(-15 * time.Minute) + type pkt struct { + t time.Time + src, dst netip.Addr + sport, dport uint16 + proto, flags uint8 + size int + } + var ps []pkt + at := func(from, d time.Duration) time.Time { + return start.Add(from + time.Duration(rng.Int64N(int64(d)))) + } + ip := func(s string) netip.Addr { return netip.MustParseAddr(s) } + office := func() netip.Addr { + return netip.AddrFrom4([4]byte{10, 10, byte(1 + rng.IntN(4)), byte(2 + rng.IntN(250))}) + } + // ordinary traffic: web, video calls, DNS, file shares + servers := []struct { + a string + port uint16 + proto uint8 + mbit float64 + }{ + {"142.250.196.110", 443, 6, 6}, {"151.101.1.140", 443, 6, 3}, {"13.107.42.14", 443, 6, 4}, + {"104.16.132.229", 443, 6, 2}, {"52.84.150.39", 443, 6, 3}, {"170.114.52.2", 8801, 17, 2.5}, + {"114.114.114.114", 53, 17, 0.05}, {"8.8.8.8", 53, 17, 0.05}, {"10.20.0.15", 445, 6, 5}, + } + clients := make([]netip.Addr, 40) + for i := range clients { + clients[i] = office() + } + for _, s := range servers { + srv := ip(s.a) + n := int(s.mbit * 1e6 / 8 / 1200 * 900 / 50) // packets in 15 minutes, at 1/50 of the rate + for i := 0; i < n; i++ { + c := clients[rng.IntN(len(clients))] + // one session per client and server + sport := uint16(49152 + (int(c.As4()[2])*251+int(c.As4()[3])+int(s.port))%16000) + size := 1200 + rng.IntN(300) + if s.port == 53 { + size = 80 + rng.IntN(60) + } + t := at(0, 15*time.Minute) + if rng.IntN(5) == 0 { // requests and acks upstream + ps = append(ps, pkt{t, c, srv, sport, s.port, s.proto, 0x18, 66 + rng.IntN(400)}) + } else { + ps = append(ps, pkt{t, srv, c, s.port, sport, s.proto, 0x18, size}) + } + } + } + pc, fileServer := ip("10.10.3.27"), ip("10.20.0.15") + // minute 3-5: SMB sweep of the office, one SYN per address + for i := 0; i < 600; i++ { + ps = append(ps, pkt{at(3*time.Minute, 2*time.Minute), pc, netip.AddrFrom4([4]byte{10, 10, byte(1 + i/250), byte(2 + i%250)}), + uint16(40000 + rng.IntN(20000)), 445, 6, 0x02, 60}) + } + // minute 1-2: every port up to 1024 on the file server + for p := 1; p <= 1024; p++ { + ps = append(ps, pkt{at(time.Minute, time.Minute), pc, fileServer, 51000, uint16(p), 6, 0x02, 60}) + } + // minute 12-15: RDP password guessing, 300 short connections + for i := 0; i < 300; i++ { + t := at(12*time.Minute, 3*time.Minute-200*time.Millisecond) + sport := uint16(50000 + i) + for j := 0; j < 8; j++ { + ps = append(ps, pkt{t.Add(time.Duration(j) * 20 * time.Millisecond), pc, fileServer, sport, 3389, 6, 0x18, 90 + rng.IntN(150)}) + } + } + sort.Slice(ps, func(i, j int) bool { return ps[i].t.Before(ps[j].t) }) + + bw := bufio.NewWriter(w) + pw, err := pcapfile.NewWriter(bw, pcapfile.LinkEthernet) + if err != nil { + return err + } + buf := make([]byte, 54) + for _, p := range ps { + n := frame(buf, p.src, p.dst, p.sport, p.dport, p.proto, p.flags, p.size) + if err := pw.Write(p.t, buf[:n], p.size); err != nil { + return err + } + } + return bw.Flush() +} + +// frame writes Ethernet, IPv4 and TCP/UDP headers for a packet of size +// bytes on the wire and returns how many header bytes were written. +func frame(b []byte, src, dst netip.Addr, sport, dport uint16, proto, flags uint8, size int) int { + clear(b) + copy(b[0:6], []byte{0, 0x1b, 0x21, 1, 2, 3}) + copy(b[6:12], []byte{0, 0x1b, 0x21, 4, 5, 6}) + binary.BigEndian.PutUint16(b[12:], 0x0800) + ipb := b[14:] + ipb[0] = 0x45 + binary.BigEndian.PutUint16(ipb[2:], uint16(max(size-14, 40))) + ipb[8] = 64 + ipb[9] = proto + s4, d4 := src.As4(), dst.As4() + copy(ipb[12:16], s4[:]) + copy(ipb[16:20], d4[:]) + l4 := ipb[20:] + binary.BigEndian.PutUint16(l4[0:], sport) + binary.BigEndian.PutUint16(l4[2:], dport) + if proto == 17 { + binary.BigEndian.PutUint16(l4[4:], uint16(max(size-34, 8))) + return 14 + 20 + 8 + } + l4[12] = 5 << 4 + l4[13] = flags + return 14 + 20 + 20 +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go new file mode 100644 index 0000000..461aa35 --- /dev/null +++ b/internal/sandbox/sandbox.go @@ -0,0 +1,516 @@ +// Package sandbox imports packet capture files into a database of their own, +// apart from the live data: the same pages and detection rules then work on +// the capture, and deleting the sandbox removes both the files and the +// database. +package sandbox + +import ( + "encoding/json" + "errors" + "fmt" + "hash/fnv" + "io" + "log" + "net/netip" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/githubflyideas/traffic66/internal/detect" + "github.com/githubflyideas/traffic66/internal/enrich" + "github.com/githubflyideas/traffic66/internal/flow" + "github.com/githubflyideas/traffic66/internal/pcapfile" + "github.com/githubflyideas/traffic66/internal/pipeline" + "github.com/githubflyideas/traffic66/internal/store" +) + +// Limits of the free edition. +var ( + MaxFiles = 3 + MaxFileSize = int64(50 << 20) +) + +// File is one capture file in the sandbox. +type File struct { + Name string `json:"name"` + Size int64 `json:"size"` + Added time.Time `json:"added"` + Sample bool `json:"sample,omitempty"` // the demo's example; not counted against the limits + Packets uint64 `json:"packets"` + Skipped uint64 `json:"skipped"` // packets without an IP header + Flows uint64 `json:"flows"` + First time.Time `json:"first"` + Last time.Time `json:"last"` + Status string `json:"status"` // waiting, importing, done, error + Error string `json:"error,omitempty"` + Exporter string `json:"exporter"` // the "device" the file's flows appear under +} + +// Sandbox holds the uploaded files and their database. +type Sandbox struct { + Dir string + Inv *enrich.Inventory // the live inventory: names of networks and hosts + ASN *enrich.ASNDB + Thr *enrich.Threats + + 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{} +} + +// ErrLimit is returned when a file would exceed the limits. +var ErrLimit = errors.New("limit") + +// ErrNotCapture is returned for files that are not pcap or pcapng. +var ErrNotCapture = errors.New("not a capture file: use .pcap or .pcapng as saved by Wireshark or tcpdump (not compressed)") + +// New opens the sandbox in dir (creating nothing until a file is added) and +// starts its import worker. +func New(dir string, inv *enrich.Inventory, asn *enrich.ASNDB, thr *enrich.Threats) *Sandbox { + sb := &Sandbox{Dir: dir, Inv: inv, ASN: asn, Thr: thr, wake: make(chan struct{}, 1)} + 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 +} + +func (sb *Sandbox) pcapDir() string { return filepath.Join(sb.Dir, "files") } +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) load() { + b, err := os.ReadFile(sb.indexPath()) + if err != nil { + return + } + var fs []*File + if json.Unmarshal(b, &fs) != nil { + return + } + for _, f := range fs { + if _, err := os.Stat(filepath.Join(sb.pcapDir(), f.Name)); err == nil { + sb.files = append(sb.files, f) + } + } + if _, err := os.Stat(filepath.Join(sb.dbDir(), "traffic66.duckdb")); err == nil { + if err := sb.open(); err != nil { + log.Printf("sandbox: %v", err) + } + } + for _, f := range sb.files { + if f.Status != "done" && f.Status != "error" { + f.Status = "waiting" + } + } +} + +func (sb *Sandbox) save() { + b, _ := json.MarshalIndent(sb.files, "", " ") + os.MkdirAll(sb.Dir, 0o755) + tmp := sb.indexPath() + ".tmp" + if os.WriteFile(tmp, b, 0o644) == nil { + os.Rename(tmp, sb.indexPath()) + } +} + +// open opens (or creates) the database; sb.mu held or not yet shared. +func (sb *Sandbox) open() error { + st, err := store.Open(store.Options{Dir: sb.dbDir(), MemoryFraction: 0.05, Threads: 2}) + if err != nil { + return err + } + sb.st = st + sb.refreshInventory() + sb.det = detect.New(st, sb.inv, detect.Config{}) + return nil +} + +// refreshInventory names each file's exporter after the file. +func (sb *Sandbox) refreshInventory() { + inv := enrich.NewInventory() + var b strings.Builder + if sb.Inv != nil { + b.WriteString(sb.Inv.Text()) + b.WriteString("\n") + } + for _, f := range sb.files { + fmt.Fprintf(&b, "device %s %s\n", f.Exporter, f.Name) + } + if err := inv.Parse(b.String()); err != nil { + log.Printf("sandbox: inventory: %v", err) + } + sb.inv = inv + if sb.det != nil { + sb.det.Inv = inv + } +} + +// Store returns the sandbox database, or nil when there is none. +func (sb *Sandbox) Store() (*store.Store, *detect.Detector, *enrich.Inventory) { + sb.mu.Lock() + defer sb.mu.Unlock() + return sb.st, sb.det, sb.inv +} + +// 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"` + MaxFiles int `json:"max_files"` + MaxFileSize int64 `json:"max_file_size"` + Ready bool `json:"ready"` // has data to look at +} + +func (sb *Sandbox) Info() Info { + sb.mu.Lock() + defer sb.mu.Unlock() + in := Info{Files: []File{}, Busy: sb.busy, MaxFiles: MaxFiles, MaxFileSize: MaxFileSize} + 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 + } + in.Ready = sb.st != nil + } + return in +} + +var unsafeName = regexp.MustCompile(`[^\p{L}\p{N}._ -]+`) + +// cleanName makes an uploaded file name safe and unique. +func (sb *Sandbox) cleanName(name string) string { + name = filepath.Base(strings.ReplaceAll(name, "\\", "/")) + name = strings.TrimSpace(unsafeName.ReplaceAllString(name, "_")) + if name == "" || name == "." || name == ".." || strings.HasPrefix(name, ".") { + name = "capture.pcap" + } + if len(name) > 100 { + name = name[len(name)-100:] + } + base, ext := name, filepath.Ext(name) + base = strings.TrimSuffix(base, ext) + for i := 2; ; i++ { + taken := false + for _, f := range sb.files { + if f.Name == name { + taken = true + } + } + if !taken { + return name + } + name = fmt.Sprintf("%s-%d%s", base, i, ext) + } +} + +// Add stores an uploaded capture file and queues it for import. The file is +// checked while it streams to disk: at most MaxFileSize bytes, and it must +// start like a pcap or pcapng file. +func (sb *Sandbox) Add(name string, r io.Reader, sample bool) (*File, error) { + sb.mu.Lock() + n := 0 + for _, f := range sb.files { + if !f.Sample { + n++ + } + } + if !sample && n >= MaxFiles { + sb.mu.Unlock() + return nil, fmt.Errorf("%w: at most %d files; delete one first", ErrLimit, MaxFiles) + } + name = sb.cleanName(name) + // reserve the name while the upload runs + f := &File{Name: name, Added: time.Now().UTC(), Status: "uploading", Sample: sample} + sb.files = append(sb.files, f) + sb.mu.Unlock() + fail := func(err error) (*File, error) { + sb.mu.Lock() + sb.files = slicesDelete(sb.files, f) + sb.mu.Unlock() + os.Remove(filepath.Join(sb.pcapDir(), name)) + return nil, err + } + if err := os.MkdirAll(sb.pcapDir(), 0o755); err != nil { + return fail(err) + } + head := make([]byte, 4) + if _, err := io.ReadFull(r, head); err != nil || pcapfile.Format(head) == "" { + return fail(ErrNotCapture) + } + path := filepath.Join(sb.pcapDir(), name) + out, err := os.Create(path) + if err != nil { + return fail(err) + } + out.Write(head) + limit := MaxFileSize + if sample { + limit = 1 << 30 + } + written, err := io.Copy(out, io.LimitReader(r, limit-4+1)) + out.Close() + if err != nil { + return fail(err) + } + if written+4 > limit { + return fail(fmt.Errorf("%w: a file may be at most %d MB", ErrLimit, MaxFileSize>>20)) + } + sb.mu.Lock() + f.Size = written + 4 + f.Status = "waiting" + f.Exporter = sb.exporterFor(f) + sb.save() + sb.refreshInventory() + sb.mu.Unlock() + sb.kick() + return f, nil +} + +// exporterFor picks a free address 127.0.1.n for a file's flows. +func (sb *Sandbox) exporterFor(f *File) string { + used := map[string]bool{} + for _, o := range sb.files { + used[o.Exporter] = true + } + for i := 1; ; i++ { + a := fmt.Sprintf("127.0.1.%d", i) + if !used[a] { + return a + } + } +} + +func slicesDelete(fs []*File, f *File) []*File { + out := fs[:0] + for _, x := range fs { + if x != f { + out = append(out, x) + } + } + return out +} + +// Delete removes one file (name) or everything (name ""). The database is +// rebuilt from the remaining files. +func (sb *Sandbox) Delete(name string) error { + sb.mu.Lock() + var keep []*File + found := name == "" + for _, f := range sb.files { + if name == "" || f.Name == name { + found = true + if f.Status == "uploading" { + keep = append(keep, f) + continue + } + os.Remove(filepath.Join(sb.pcapDir(), f.Name)) + continue + } + keep = append(keep, f) + } + if !found { + sb.mu.Unlock() + return errors.New("no such file") + } + sb.files = keep + sb.gen++ + 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.save() + sb.refreshInventory() + sb.mu.Unlock() + if st != nil { + st.Close() + } + if err := os.RemoveAll(sb.dbDir()); err != nil { + return err + } + if len(keep) == 0 { + os.RemoveAll(sb.Dir) + } + sb.kick() + return nil +} + +func (sb *Sandbox) kick() { + select { + case sb.wake <- struct{}{}: + default: + } +} + +func (sb *Sandbox) worker() { + for range sb.wake { + for sb.importNext() { + } + } +} + +// importNext imports one waiting file; it reports whether it did. +func (sb *Sandbox) importNext() bool { + sb.mu.Lock() + var f *File + for _, x := range sb.files { + if x.Status == "waiting" { + f = x + break + } + } + if f == nil { + 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 + } + } + f.Status = "importing" + sb.busy = true + gen, st, det, inv := sb.gen, sb.st, sb.det, sb.inv + sb.mu.Unlock() + + res, err := sb.importFile(gen, f, st, inv) + if err == nil { + // the rules over the whole capture, window by window + every := det.Cfg.Every + for t := res.first.Truncate(every).Add(every); !t.After(res.last.Add(every)); t = t.Add(every) { + if _, err := det.Run(t); err != nil { + log.Printf("sandbox: detect: %v", err) + break + } + } + } + sb.mu.Lock() + defer sb.mu.Unlock() + sb.busy = false + if gen != sb.gen { // deleted meanwhile + return true + } + f.Packets, f.Skipped, f.Flows, f.First, f.Last = res.packets, res.skipped, res.flows, res.first, res.last + switch { + case err != nil: + f.Status, f.Error = "error", err.Error() + case res.flows == 0: + f.Status, f.Error = "error", "no IP packets found" + default: + f.Status = "done" + } + 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) + return true +} + +type result struct { + packets, skipped, flows uint64 + first, last time.Time +} + +func (sb *Sandbox) importFile(gen int, f *File, st *store.Store, inv *enrich.Inventory) (result, error) { + var res result + in, err := os.Open(filepath.Join(sb.pcapDir(), f.Name)) + if err != nil { + return res, err + } + defer in.Close() + rd, err := pcapfile.NewReader(in) + if err != nil { + return res, err + } + p := pipeline.New(pipeline.Config{}, st, inv, sb.ASN, sb.Thr) + h := fnv.New32a() + h.Write([]byte(f.Name)) + exp, _ := netip.ParseAddr(f.Exporter) + n := 0 + b := newBuilder(exp, h.Sum32(), func(recs []flow.Record) { + res.flows += uint64(len(recs)) + p.Ingest(recs) + if n++; n%50 == 0 { + p.FlushRows() + } + }) + var pk pcapfile.Packet + for { + err := rd.Next(&pk) + if err == io.EOF { + break + } + if err != nil { + // keep what was read before the damage + log.Printf("sandbox: %s: %v", f.Name, err) + break + } + if pk.Time.Year() < 1995 || pk.Time.After(time.Now().Add(24*time.Hour)) { + res.skipped++ + continue + } + if res.first.IsZero() || pk.Time.Before(res.first) { + res.first = pk.Time + } + if pk.Time.After(res.last) { + res.last = pk.Time + } + b.add(&pk) + if rd.Count%100000 == 0 { + sb.mu.Lock() + stale := gen != sb.gen + f.Packets = rd.Count + sb.mu.Unlock() + if stale { + return res, errors.New("deleted") + } + } + } + b.sweep(res.last.Add(time.Hour), true) + p.FlushRows() + p.FlushRollups() + res.packets, res.skipped = rd.Count, res.skipped+b.skipped + return res, nil +} + +// Close closes the database. +func (sb *Sandbox) Close() { + sb.mu.Lock() + st := sb.st + sb.st = nil + sb.mu.Unlock() + if st != nil { + st.Close() + } +} diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go new file mode 100644 index 0000000..c9d5545 --- /dev/null +++ b/internal/sandbox/sandbox_test.go @@ -0,0 +1,134 @@ +package sandbox + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/githubflyideas/traffic66/internal/enrich" + "github.com/githubflyideas/traffic66/internal/store" +) + +func wait(t *testing.T, sb *Sandbox) Info { + t.Helper() + for i := 0; i < 600; i++ { + in := sb.Info() + pending := false + for _, f := range in.Files { + if f.Status != "done" && f.Status != "error" { + pending = true + } + } + if !pending && !in.Busy { + return in + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("import did not finish") + return Info{} +} + +// The example capture is imported into its own database, the attack in it +// is found, and deleting removes everything. +func TestSampleImport(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sandbox") + inv := enrich.NewInventory() + inv.Parse("net 10.10.0.0/16 Office\nnet 10.20.0.0/24 Servers\nhost 10.20.0.15 File server\n") + sb := New(dir, inv, enrich.NewASNDB(), enrich.NewThreats()) + defer sb.Close() + var buf bytes.Buffer + end := time.Date(2026, 9, 20, 11, 0, 0, 0, time.UTC) + if err := Sample(&buf, end); err != nil { + t.Fatal(err) + } + t.Logf("sample: %d bytes", buf.Len()) + if _, err := sb.Add(SampleName, bytes.NewReader(buf.Bytes()), true); err != nil { + t.Fatal(err) + } + in := wait(t, sb) + f := in.Files[0] + if f.Status != "done" || f.Flows == 0 || !in.Ready { + t.Fatalf("%+v", f) + } + if f.First.Before(end.Add(-16*time.Minute)) || f.Last.After(end) { + t.Errorf("span %v – %v", f.First, f.Last) + } + st, _, _ := sb.Store() + fs, err := st.Findings(store.FindingQuery{From: f.First.Add(-time.Hour), To: f.Last.Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + kinds := map[string]bool{} + for _, x := range fs { + kinds[x.Kind] = true + t.Logf("%s %s -> %s %s sev %d", x.Kind, x.Src, x.Dst, x.Port, x.Sev) + } + for _, k := range []string{"scan", "portscan", "brute"} { + if !kinds[k] { + t.Errorf("no %s finding", k) + } + } + var wire float64 + st.DB.QueryRow(`SELECT sum(wire) FROM hot`).Scan(&wire) + if wire < 20e6 { + t.Errorf("traffic %.0f bytes", wire) + } + if err := sb.Delete(""); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("sandbox directory left: %v", err) + } + if st, _, _ := sb.Store(); st != nil { + t.Error("database still open") + } +} + +func TestLimits(t *testing.T) { + old := MaxFileSize + MaxFileSize = 1000 + defer func() { MaxFileSize = old }() + sb := New(t.TempDir(), enrich.NewInventory(), enrich.NewASNDB(), enrich.NewThreats()) + defer sb.Close() + var small bytes.Buffer + Sample(&small, time.Now()) + head := small.Bytes()[:800] + if _, err := sb.Add("x.pcap", bytes.NewReader(small.Bytes()), false); !errors.Is(err, ErrLimit) { + t.Fatalf("big file: %v", err) + } + if _, err := sb.Add("x.txt", strings.NewReader("hello world"), false); err == nil || errors.Is(err, ErrLimit) { + t.Fatalf("text file: %v", err) + } + for i := 0; i < 3; i++ { + if _, err := sb.Add("a.pcap", bytes.NewReader(head), false); err != nil { + t.Fatal(err) + } + } + if _, err := sb.Add("d.pcap", bytes.NewReader(head), false); !errors.Is(err, ErrLimit) { + t.Fatalf("fourth file: %v", err) + } + in := wait(t, sb) + names := []string{} + for _, f := range in.Files { + names = append(names, f.Name+":"+f.Exporter) + } + if strings.Join(names, " ") != "a.pcap:127.0.1.1 a-2.pcap:127.0.1.2 a-3.pcap:127.0.1.3" { + t.Errorf("files %v", names) + } + if err := sb.Delete("a-2.pcap"); err != nil { + t.Fatal(err) + } + if in := wait(t, sb); len(in.Files) != 2 { + t.Errorf("%d files after delete", len(in.Files)) + } + if _, err := sb.Add("../../etc/passwd.pcap", bytes.NewReader(head), false); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(sb.Dir, "files", "passwd.pcap")); err != nil { + t.Errorf("name not cleaned: %v", err) + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 7556836..9e0a7e4 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -243,3 +243,11 @@ th.sk .ar{font-size:10px;margin-inline-start:3px;opacity:.7} .attrib{font-size:11.5px;margin:8px 0 0} .attrib a{color:inherit} h3.subhead{font-size:14px;font-weight:650;margin:20px 0 4px} +.sbbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:0 20px 8px;padding:8px 12px;border-radius:8px;background:#fff4e0;border:1px solid #f0c27a;font-size:13px} +.sbbar[hidden]{display:none} +.sbtag{font-weight:700;color:#8a4b00} +.sbfiles{font-weight:600} +.sbbar .btn{margin-inline-start:auto} +.sbexplain{font-size:13px;color:var(--ink-2);margin:0 0 12px} +.sbactions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:12px} +label.btn.disabled{opacity:.5;cursor:not-allowed!important} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 1c21a1e..8671162 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -140,7 +140,7 @@ async function resolveNames() { } // ------------------------------------------------------------ state & API -const VIEWS = ['overview', 'findings', 'topn', 'traffic', 'sankey', 'geo', 'threats', 'records', 'ifaces', 'sources', 'detail']; +const VIEWS = ['overview', 'findings', 'topn', 'traffic', 'sankey', 'geo', 'threats', 'records', 'sandbox', 'ifaces', 'sources', 'detail']; const RANGES = ['15m', '1h', '6h', '24h', '7d', '30d']; const state = {v: 'overview', r: '24h', f: [], dim: 'conv', ifc: null, ifdir: 'in', sort: {k: 'wire', asc: false}, det: null}; function readHash() { @@ -151,6 +151,7 @@ function readHash() { state.tm = p.get('tm') === 'table' || (p.get('v') === 'topn' && p.get('dim')) ? 'table' : 'talkers'; state.sk = ['segment', 'conv'].includes(p.get('by')) ? p.get('by') : 'host'; state.side = ['client', 'both', 'service'].includes(p.get('side')) ? p.get('side') : 'server'; + state.ds = p.get('ds') === 'sb' ? 'sb' : ''; const dd = p.get('d'); if (dd && dd.includes(':')) state.det = {f: dd.slice(0, dd.indexOf(':')), v: dd.slice(dd.indexOf(':') + 1)}; state.f = (p.get('f') || '').split(',').filter(Boolean).map(s => { const neg = s[0] === '!'; if (neg) s = s.slice(1); @@ -160,6 +161,7 @@ function readHash() { } function writeHash(push) { const p = new URLSearchParams({v: state.v, r: state.r}); + if (state.ds) p.set('ds', state.ds); if (state.v === 'topn') { if (state.tm === 'table') { p.set('tm', 'table'); p.set('dim', state.dim); } } if (state.v === 'sankey' && state.sk !== 'host') p.set('by', state.sk); if (state.v === 'traffic' && state.side !== 'server') p.set('side', state.side); @@ -168,8 +170,13 @@ function writeHash(push) { const h = '#' + p.toString(); if (push && h !== location.hash) history.pushState(null, '', h); else history.replaceState(null, '', h); } +// pages whose data can come from the sandbox (uploaded capture files) +const SB_DATA = new Set(['overview', 'topn', 'sankey', 'series', 'records', 'threats', 'findings']); +const inSB = () => state.ds === 'sb' && sbInfo.ready; +let sbInfo = {files: [], ready: false}; async function api(path, extra = {}, filters = state.f) { - const p = new URLSearchParams({range: state.r, ...extra}); + if (inSB() && (path === 'ifaces' || path === 'recon')) return {ifaces: []}; + const p = new URLSearchParams(inSB() && SB_DATA.has(path) ? {ds: 'sb', ...sbRange(), ...extra} : {range: state.r, ...extra}); if (filters.length) p.set('f', JSON.stringify(filters.map(x => ({f: x.f, v: x.v, neg: x.neg})))); const res = await fetch('/api/' + path + '?' + p); if (res.status === 401) { showLogin(); throw new Error('login'); } @@ -177,7 +184,13 @@ async function api(path, extra = {}, filters = state.f) { if (!res.ok) { const e = new Error(j.error || res.statusText); e.kind = j.kind; throw e; } return j; } -const spanMs = () => ({'15m': 9e5, '1h': 36e5, '6h': 216e5, '24h': 864e5, '7d': 6048e5, '30d': 2592e6})[state.r]; +// the sandbox's time range: the whole capture, on whole minutes +function sbRange() { + const a = Math.floor(Date.parse(sbInfo.first) / 6e4) * 6e4, b = Math.ceil((Date.parse(sbInfo.last) + 1) / 6e4) * 6e4; + return {from: a, to: Math.max(b, a + 6e4)}; +} +const spanMs = () => { if (inSB()) { const r = sbRange(); return r.to - r.from; } return ({'15m': 9e5, '1h': 36e5, '6h': 216e5, '24h': 864e5, '7d': 6048e5, '30d': 2592e6})[state.r]; }; +const rangeLabel = () => inSB() ? t('sb.range') : t('range.' + state.r); // ------------------------------------------------------------ tooltip & charts const tip = $('#tip'); @@ -372,7 +385,7 @@ function renderFilters() { // values; choosing one replaces any filter on that field, emptying it // removes the filter. const FBAR = [['exporter', 'field.exporter'], ['client', 'field.client'], ['server', 'field.server'], ['port', 'col.service']]; -const FBAR_HIDDEN = new Set(['findings', 'sources']); +const FBAR_HIDDEN = new Set(['findings', 'sources', 'sandbox']); function renderFbar() { const bar = $('#fbar'); bar.hidden = FBAR_HIDDEN.has(state.v); @@ -456,7 +469,7 @@ views.overview = async (el) => { el.innerHTML = `
${t('kpi.now')}
${fmtBps(d.now_bps)}
${change == null ? ' ' : esc(t(basis === 'week' ? 'kpi.vs_week' : 'kpi.vs_prev', {p: fmtPct(change, 0)}))}
-
${t('kpi.total', {r: t('range.' + state.r)})}
${fmtBytes(tot.wire)}
${esc(peakTxt)}
+
${t('kpi.total', {r: rangeLabel()})}
${fmtBytes(tot.wire)}
${esc(peakTxt)}
${t('kpi.hosts')}
${nf(tot.hosts)}
 
${t('kpi.peers')}
${nf(tot.peers)}
${esc(t('kpi.countries', {n: nf(tot.countries)}))}
${t('kpi.accuracy')}
${acc.n}
${acc.kind ? status(acc.kind, acc.d) : `${esc(acc.d)}`}
@@ -559,7 +572,7 @@ function bindFindings(el) { el.querySelectorAll('[data-go]').forEach(b => b.onclick = () => go(b.dataset.go)); el.querySelectorAll('[data-fd-view]').forEach(b => b.onclick = () => showDetail('ip', b.dataset.fdView)); el.querySelectorAll('[data-fd-set]').forEach(b => b.onclick = async () => { - const res = await fetch('/api/findings', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ids: [+b.dataset.id], status: b.dataset.fdSet})}); + const res = await fetch('/api/findings' + (inSB() ? '?ds=sb' : ''), {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ids: [+b.dataset.id], status: b.dataset.fdSet})}); if (!res.ok) { const j = await res.json().catch(() => ({})); toast(j.error || res.statusText); return; } loadStatus(); render(); }); @@ -888,6 +901,67 @@ views.geo = async (el) => { asCharts.forEach(([by, m], i) => fillSeries('asr' + i, ad[i], by, m)); }; +// ------------------------------------------------------------ offline analysis +// Capture files are imported into a database of their own; "Analyse" shows +// them on all the other pages, apart from the live data. +let sbMsg = ''; +views.sandbox = async (el) => { + await loadSB(); + const fs = sbInfo.files || [], own = fs.filter(f => !f.sample).length, mb = Math.round(sbInfo.max_file_size / 1048576); + const df = new Intl.DateTimeFormat(LANG, {dateStyle: 'short', timeStyle: 'medium'}); + const pending = fs.some(f => f.status !== 'done' && f.status !== 'error') || sbInfo.busy; + const rows = fs.map(f => `${esc(f.name)}${f.sample ? ` ${t('sb.sample')}` : ''}${fmtBytes(f.size)} + ${f.packets ? nf(f.packets) : '—'}${f.flows ? nf(f.flows) : '—'} + ${f.flows ? esc(df.format(new Date(f.first))) + ' – ' + esc(df.format(new Date(f.last))) : ''} + ${f.status === 'error' ? status('bad', f.error || t('sb.st_error')) : f.status === 'done' ? status('ok', t('sb.st_done')) : `${t('sb.st_' + f.status)}…`} + `).join(''); + el.innerHTML = `
${panel('c12', t('sb.title'), t('sb.sub'), `

${esc(t('sb.explain'))}

+ + ${rows || ``}
${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')}
+
+ + ${fs.length ? `` : ''}
+

${esc(t('sb.limits', {n: sbInfo.max_files, mb}))} ${esc(t('sb.formats'))}

`)}
`; + // the last upload problem stays shown after the page refreshes + const msg = (ok, text) => { sbMsg = ok === false ? text : ''; const m = $('#sbMsg'); m.style.color = ok === null ? 'var(--ink-3)' : ok ? 'var(--good)' : 'var(--crit)'; m.textContent = text; }; + if (sbMsg) msg(false, sbMsg); + $('#sbGo').onclick = () => { state.ds = 'sb'; go('overview'); }; + el.querySelectorAll('[data-sbdel]').forEach(b => b.onclick = async () => { + if (!confirm(t('sb.confirm', {f: b.dataset.sbdel}))) return; + await fetch('/api/sandbox/files?name=' + encodeURIComponent(b.dataset.sbdel), {method: 'DELETE'}); + render(); + }); + if ($('#sbAll')) $('#sbAll').onclick = async () => { + if (!confirm(t('sb.confirm_all'))) return; + await fetch('/api/sandbox/files', {method: 'DELETE'}); + state.ds = ''; render(); + }; + $('#sbFile').onchange = async e => { + const files = [...e.target.files]; + msg(null, ''); + let left = sbInfo.max_files - own; + for (const f of files) { + if (left <= 0) { msg(false, t('sb.too_many', {n: sbInfo.max_files})); break; } + if (f.size > sbInfo.max_file_size) { msg(false, t('sb.too_big', {f: f.name, mb})); continue; } + const ok = await new Promise(done => { + const x = new XMLHttpRequest(); + x.open('POST', '/api/sandbox/files?name=' + encodeURIComponent(f.name)); + x.upload.onprogress = ev => ev.lengthComputable && msg(null, t('sb.uploading', {f: f.name, p: Math.round(100 * ev.loaded / ev.total)})); + x.onload = () => { + if (x.status === 200) return done(true); + let j = {}; try { j = JSON.parse(x.responseText); } catch (err) {} + msg(false, x.status === 413 ? (j.max_files && !/MB/.test(j.error) ? t('sb.too_many', {n: sbInfo.max_files}) : t('sb.too_big', {f: f.name, mb})) : j.code === 'not_capture' ? t('sb.not_capture', {f: f.name}) : (j.error || x.statusText)); done(false); + }; + x.onerror = () => { msg(false, t('sb.failed')); done(false); }; + x.send(f); + }); + if (ok) left--; + } + render(); + }; + if (pending) setTimeout(() => { if (state.v === 'sandbox') render(); }, 1500); +}; + views.threats = async (el) => { const d = await api('threats', {limit: 66}); const rows = d.rows || [], lists = d.lists || {}; @@ -935,7 +1009,7 @@ views.records = async (el) => { const pager = `
${esc(t('rec.range', {a: nf(total ? page * per + 1 : 0), b: nf(Math.min(total, page * per + rows.length)), n: nf(total)}))}
`; - el.innerHTML = `
${t('rec.title')}
${nf(total)}
${esc(t('rec.in', {r: t('range.' + state.r)}))}
+ el.innerHTML = `
${t('rec.title')}
${nf(total)}
${esc(t('rec.in', {r: rangeLabel()}))}

${t('rec.title')}

${esc(t('rec.sorted'))}
@@ -1126,7 +1200,7 @@ views.detail = async (el) => {

${title}

-
${t('kpi.total', {r: t('range.' + state.r)})}
${fmtBytes(tot.wire)}
${d.peak_at ? esc(t('kpi.peak', {v: fmtBps(d.peak_bps), t: fmtTime(d.peak_at, spanMs())})) : ' '}
+
${t('kpi.total', {r: rangeLabel()})}
${fmtBytes(tot.wire)}
${d.peak_at ? esc(t('kpi.peak', {v: fmtBps(d.peak_bps), t: fmtTime(d.peak_at, spanMs())})) : ' '}
${t('kpi.now')}
${fmtBps(d.now_bps)}
 
${t(isIP ? 'det.peers' : 'det.clients')}
${nf(isIP ? tot.peers + tot.hosts - 1 : tot.hosts)}
 
${t('kpi.peers')}
${nf(tot.peers)}
${esc(t('kpi.countries', {n: nf(tot.countries)}))}
@@ -1147,7 +1221,34 @@ views.detail = async (el) => { }; // ------------------------------------------------------------ shell +// The sandbox bar replaces the time range while capture files are shown. +function renderSB() { + const on = inSB() && !['ifaces', 'sources'].includes(state.v); + $('#sbbar').hidden = !on; + $('#range').hidden = on; + $('#refresh').hidden = on; + document.querySelectorAll('#nav [data-v=ifaces], #nav [data-v=sources]').forEach(b => b.hidden = inSB()); + if (!on) return; + const r = sbRange(), span = r.to - r.from; + const df = new Intl.DateTimeFormat(LANG, {dateStyle: 'medium', timeStyle: 'short'}), tf = new Intl.DateTimeFormat(LANG, {timeStyle: 'short'}); + const sameDay = new Date(r.from).toDateString() === new Date(r.to).toDateString(); + const names = sbInfo.files.filter(f => f.status === 'done').map(f => f.name); + $('#sbbar').innerHTML = `${t('sb.banner')}${names.map(esc).join(' · ')} + ${esc(df.format(r.from))} – ${esc(sameDay ? tf.format(r.to) : df.format(r.to))} (${esc(fmtDur(span))}) + `; + $('#sbBack').onclick = () => { state.ds = ''; render(); }; +} +function fmtDur(ms) { + const m = Math.round(ms / 6e4); + return m < 120 ? t('sb.min', {n: nf(m)}) : t('sb.hours', {n: nf(m / 60, 1)}); +} +async function loadSB() { + try { const res = await fetch('/api/sandbox'); if (res.ok) sbInfo = await res.json(); } catch (e) {} + if (state.ds && !sbInfo.ready) state.ds = ''; +} + async function render(push) { + renderSB(); renderFilters(); renderFbar(); writeHash(push); @@ -1221,7 +1322,7 @@ async function init() { const res = await fetch('/api/status'); if (res.status === 401) { showLogin(); return; } $('#app').hidden = false; - await loadStatus(); + await Promise.all([loadStatus(), loadSB()]); render(); setInterval(loadStatus, 10000); setInterval(() => { diff --git a/internal/web/static/i18n/ar.json b/internal/web/static/i18n/ar.json index 942577a..0b667ef 100644 --- a/internal/web/static/i18n/ar.json +++ b/internal/web/static/i18n/ar.json @@ -376,5 +376,40 @@ "geo.map_none": "لا حركة", "geo.data_from": "بيانات الدول والشبكات:", "geo.map_from": "الخريطة:", -"td.both": "الطرفان" +"td.both": "الطرفان", +"nav.sandbox": "التحليل دون اتصال", +"sb.title": "ملفات الالتقاط", +"sb.sub": "حلّل ملفات pcap بمعزل عن البيانات الحية", +"sb.explain": "ارفع ملفات التقاط من Wireshark أو tcpdump. تُحفظ في قاعدة بيانات منفصلة فلا تتأثر البيانات الحية وأرقامها واكتشافاتها. يعرض زر «تحليل» الملفات في كل الصفحات (نظرة عامة، أعلى 66، الاكتشافات، مسارات الحركة، الخريطة، سجلات التدفق)، ويحذف زر «حذف» الملفات وبياناتها.", +"sb.col_file": "الملف", +"sb.col_size": "الحجم", +"sb.col_packets": "الحزم", +"sb.col_flows": "التدفقات", +"sb.col_span": "الوقت", +"sb.col_status": "الحالة", +"sb.st_uploading": "جارٍ الرفع", +"sb.st_waiting": "في الانتظار", +"sb.st_importing": "جارٍ الاستيراد", +"sb.st_done": "تم الاستيراد", +"sb.st_error": "فشل", +"sb.sample": "مثال", +"sb.delete": "حذف", +"sb.delete_all": "حذف الكل", +"sb.confirm": "حذف {f} وبياناته؟", +"sb.confirm_all": "حذف كل ملفات الالتقاط وبياناتها؟", +"sb.analyze": "تحليل", +"sb.upload": "رفع ملفات التقاط…", +"sb.uploading": "جارٍ رفع {f}: {p}%", +"sb.failed": "فشل الرفع", +"sb.too_big": "{f} أكبر من {mb} MB", +"sb.too_many": "{n} ملفات كحد أقصى؛ احذف واحداً أولاً", +"sb.limits": "حتى {n} ملفات، كل منها {mb} MB كحد أقصى.", +"sb.formats": "الصيغ: ‎.pcap و‎.pcapng (غير مضغوطة).", +"sb.empty": "لا توجد ملفات التقاط بعد.", +"sb.banner": "التحليل دون اتصال", +"sb.back": "العودة إلى البيانات الحية", +"sb.range": "الالتقاط كاملاً", +"sb.min": "{n} دقيقة", +"sb.hours": "{n} ساعة", +"sb.not_capture": "{f} ليس ملف التقاط. استخدم ‎.pcap أو ‎.pcapng محفوظاً من Wireshark أو tcpdump." } diff --git a/internal/web/static/i18n/bn.json b/internal/web/static/i18n/bn.json index db553ee..2c46cf9 100644 --- a/internal/web/static/i18n/bn.json +++ b/internal/web/static/i18n/bn.json @@ -376,5 +376,40 @@ "geo.map_none": "ট্রাফিক নেই", "geo.data_from": "দেশ ও নেটওয়ার্ক ডেটা:", "geo.map_from": "মানচিত্র:", -"td.both": "দুই প্রান্ত" +"td.both": "দুই প্রান্ত", +"nav.sandbox": "অফলাইন বিশ্লেষণ", +"sb.title": "ক্যাপচার ফাইল", +"sb.sub": "লাইভ ডেটা থেকে আলাদা করে pcap ফাইল বিশ্লেষণ", +"sb.explain": "Wireshark বা tcpdump-এর ক্যাপচার ফাইল আপলোড করুন। এগুলো আলাদা ডেটাবেসে যায়: লাইভ ডেটা, তার হিসাব ও ফলাফল বদলায় না। “বিশ্লেষণ” সব পেজে (সংক্ষেপ, Top 66, ফলাফল, ফ্লো পথ, মানচিত্র, ফ্লো রেকর্ড) ফাইলগুলো দেখায়; “মুছুন” ফাইল ও তার ডেটা মুছে দেয়।", +"sb.col_file": "ফাইল", +"sb.col_size": "আকার", +"sb.col_packets": "প্যাকেট", +"sb.col_flows": "ফ্লো", +"sb.col_span": "সময়", +"sb.col_status": "অবস্থা", +"sb.st_uploading": "আপলোড হচ্ছে", +"sb.st_waiting": "অপেক্ষায়", +"sb.st_importing": "ইমপোর্ট হচ্ছে", +"sb.st_done": "ইমপোর্ট হয়েছে", +"sb.st_error": "ব্যর্থ", +"sb.sample": "উদাহরণ", +"sb.delete": "মুছুন", +"sb.delete_all": "সব মুছুন", +"sb.confirm": "{f} ও তার ডেটা মুছবেন?", +"sb.confirm_all": "সব ক্যাপচার ফাইল ও তাদের ডেটা মুছবেন?", +"sb.analyze": "বিশ্লেষণ", +"sb.upload": "ক্যাপচার ফাইল আপলোড করুন…", +"sb.uploading": "{f} আপলোড হচ্ছে: {p}%", +"sb.failed": "আপলোড ব্যর্থ", +"sb.too_big": "{f} {mb} MB-এর বেশি", +"sb.too_many": "সর্বোচ্চ {n}টি ফাইল; আগে একটি মুছুন", +"sb.limits": "সর্বোচ্চ {n}টি ফাইল, প্রতিটি {mb} MB পর্যন্ত।", +"sb.formats": "ফরম্যাট: .pcap ও .pcapng (সংকুচিত নয়)।", +"sb.empty": "এখনও কোনো ক্যাপচার ফাইল নেই।", +"sb.banner": "অফলাইন বিশ্লেষণ", +"sb.back": "লাইভ ডেটায় ফিরুন", +"sb.range": "পুরো ক্যাপচার", +"sb.min": "{n} মিনিট", +"sb.hours": "{n} ঘণ্টা", +"sb.not_capture": "{f} ক্যাপচার ফাইল নয়। Wireshark বা tcpdump-এ সংরক্ষিত .pcap বা .pcapng ব্যবহার করুন।" } diff --git a/internal/web/static/i18n/en.json b/internal/web/static/i18n/en.json index a576efb..d4e239b 100644 --- a/internal/web/static/i18n/en.json +++ b/internal/web/static/i18n/en.json @@ -376,5 +376,40 @@ "geo.map_none": "no traffic", "geo.data_from": "Countries and networks:", "geo.map_from": "Map:", -"td.both": "Both ends" +"td.both": "Both ends", +"nav.sandbox": "Offline analysis", +"sb.title": "Capture files", +"sb.sub": "Analyse pcap files apart from the live data", +"sb.explain": "Upload packet captures from Wireshark or tcpdump. They go into a separate database: the live data, its numbers and findings are not touched. Analyse shows the files on every page (overview, Top 66, findings, flow paths, map, flow records); Delete removes the files and their data.", +"sb.col_file": "File", +"sb.col_size": "Size", +"sb.col_packets": "Packets", +"sb.col_flows": "Flows", +"sb.col_span": "Time", +"sb.col_status": "Status", +"sb.st_uploading": "uploading", +"sb.st_waiting": "waiting", +"sb.st_importing": "importing", +"sb.st_done": "Imported", +"sb.st_error": "Failed", +"sb.sample": "example", +"sb.delete": "Delete", +"sb.delete_all": "Delete all", +"sb.confirm": "Delete {f} and its data?", +"sb.confirm_all": "Delete all capture files and their data?", +"sb.analyze": "Analyse", +"sb.upload": "Upload capture files…", +"sb.uploading": "Uploading {f}: {p}%", +"sb.failed": "Upload failed", +"sb.too_big": "{f} is larger than {mb} MB", +"sb.too_many": "At most {n} files; delete one first", +"sb.limits": "Up to {n} files, each at most {mb} MB.", +"sb.formats": "Formats: .pcap and .pcapng (not compressed).", +"sb.empty": "No capture files yet.", +"sb.banner": "Offline analysis", +"sb.back": "Back to live data", +"sb.range": "whole capture", +"sb.min": "{n} min", +"sb.hours": "{n} h", +"sb.not_capture": "{f} is not a capture file. Use .pcap or .pcapng as saved by Wireshark or tcpdump." } diff --git a/internal/web/static/i18n/es.json b/internal/web/static/i18n/es.json index 08fc575..502e418 100644 --- a/internal/web/static/i18n/es.json +++ b/internal/web/static/i18n/es.json @@ -376,5 +376,40 @@ "geo.map_none": "sin tráfico", "geo.data_from": "Países y redes:", "geo.map_from": "Mapa:", -"td.both": "Ambos extremos" +"td.both": "Ambos extremos", +"nav.sandbox": "Análisis offline", +"sb.title": "Archivos de captura", +"sb.sub": "Analizar archivos pcap aparte de los datos en vivo", +"sb.explain": "Suba capturas de Wireshark o tcpdump. Van a una base de datos aparte: los datos en vivo, sus cifras y hallazgos no se tocan. Analizar muestra los archivos en todas las páginas (resumen, Top 66, hallazgos, rutas, mapa, registros); Eliminar borra los archivos y sus datos.", +"sb.col_file": "Archivo", +"sb.col_size": "Tamaño", +"sb.col_packets": "Paquetes", +"sb.col_flows": "Flujos", +"sb.col_span": "Tiempo", +"sb.col_status": "Estado", +"sb.st_uploading": "subiendo", +"sb.st_waiting": "en espera", +"sb.st_importing": "importando", +"sb.st_done": "Importado", +"sb.st_error": "Error", +"sb.sample": "ejemplo", +"sb.delete": "Eliminar", +"sb.delete_all": "Eliminar todo", +"sb.confirm": "¿Eliminar {f} y sus datos?", +"sb.confirm_all": "¿Eliminar todos los archivos de captura y sus datos?", +"sb.analyze": "Analizar", +"sb.upload": "Subir archivos de captura…", +"sb.uploading": "Subiendo {f}: {p}%", +"sb.failed": "Error al subir", +"sb.too_big": "{f} supera {mb} MB", +"sb.too_many": "Como máximo {n} archivos; elimine uno primero", +"sb.limits": "Hasta {n} archivos, cada uno de {mb} MB como máximo.", +"sb.formats": "Formatos: .pcap y .pcapng (sin comprimir).", +"sb.empty": "Aún no hay archivos de captura.", +"sb.banner": "Análisis offline", +"sb.back": "Volver a los datos en vivo", +"sb.range": "toda la captura", +"sb.min": "{n} min", +"sb.hours": "{n} h", +"sb.not_capture": "{f} no es un archivo de captura. Use .pcap o .pcapng guardado con Wireshark o tcpdump." } diff --git a/internal/web/static/i18n/fr.json b/internal/web/static/i18n/fr.json index 006b3ca..1e95fe1 100644 --- a/internal/web/static/i18n/fr.json +++ b/internal/web/static/i18n/fr.json @@ -376,5 +376,40 @@ "geo.map_none": "aucun trafic", "geo.data_from": "Pays et réseaux :", "geo.map_from": "Carte :", -"td.both": "Les deux côtés" +"td.both": "Les deux côtés", +"nav.sandbox": "Analyse hors ligne", +"sb.title": "Fichiers de capture", +"sb.sub": "Analyser des fichiers pcap à part des données en direct", +"sb.explain": "Importez des captures Wireshark ou tcpdump. Elles vont dans une base séparée : les données en direct, leurs chiffres et détections ne sont pas touchés. Analyser affiche les fichiers sur toutes les pages (vue d'ensemble, Top 66, détections, chemins, carte, enregistrements) ; Supprimer efface les fichiers et leurs données.", +"sb.col_file": "Fichier", +"sb.col_size": "Taille", +"sb.col_packets": "Paquets", +"sb.col_flows": "Flux", +"sb.col_span": "Période", +"sb.col_status": "État", +"sb.st_uploading": "envoi", +"sb.st_waiting": "en attente", +"sb.st_importing": "import", +"sb.st_done": "Importé", +"sb.st_error": "Échec", +"sb.sample": "exemple", +"sb.delete": "Supprimer", +"sb.delete_all": "Tout supprimer", +"sb.confirm": "Supprimer {f} et ses données ?", +"sb.confirm_all": "Supprimer tous les fichiers de capture et leurs données ?", +"sb.analyze": "Analyser", +"sb.upload": "Importer des fichiers de capture…", +"sb.uploading": "Envoi de {f} : {p} %", +"sb.failed": "Échec de l'envoi", +"sb.too_big": "{f} dépasse {mb} Mo", +"sb.too_many": "{n} fichiers au plus ; supprimez-en un d'abord", +"sb.limits": "Jusqu'à {n} fichiers de {mb} Mo au plus chacun.", +"sb.formats": "Formats : .pcap et .pcapng (non compressés).", +"sb.empty": "Aucun fichier de capture.", +"sb.banner": "Analyse hors ligne", +"sb.back": "Retour aux données en direct", +"sb.range": "toute la capture", +"sb.min": "{n} min", +"sb.hours": "{n} h", +"sb.not_capture": "{f} n'est pas un fichier de capture. Utilisez un .pcap ou .pcapng enregistré par Wireshark ou tcpdump." } diff --git a/internal/web/static/i18n/hi.json b/internal/web/static/i18n/hi.json index 4a38501..d7f22d7 100644 --- a/internal/web/static/i18n/hi.json +++ b/internal/web/static/i18n/hi.json @@ -376,5 +376,40 @@ "geo.map_none": "कोई ट्रैफ़िक नहीं", "geo.data_from": "देश और नेटवर्क डेटा:", "geo.map_from": "नक़्शा:", -"td.both": "दोनों छोर" +"td.both": "दोनों छोर", +"nav.sandbox": "ऑफ़लाइन विश्लेषण", +"sb.title": "कैप्चर फ़ाइलें", +"sb.sub": "लाइव डेटा से अलग pcap फ़ाइलों का विश्लेषण", +"sb.explain": "Wireshark या tcpdump की कैप्चर फ़ाइलें अपलोड करें। वे अलग डेटाबेस में जाती हैं: लाइव डेटा, उसके आँकड़े और निष्कर्ष नहीं बदलते। “विश्लेषण करें” सभी पेजों (अवलोकन, Top 66, निष्कर्ष, फ़्लो पथ, मानचित्र, फ़्लो रिकॉर्ड) पर फ़ाइलें दिखाता है; “हटाएँ” फ़ाइलें और उनका डेटा मिटा देता है।", +"sb.col_file": "फ़ाइल", +"sb.col_size": "आकार", +"sb.col_packets": "पैकेट", +"sb.col_flows": "फ़्लो", +"sb.col_span": "समय", +"sb.col_status": "स्थिति", +"sb.st_uploading": "अपलोड हो रही", +"sb.st_waiting": "कतार में", +"sb.st_importing": "आयात हो रही", +"sb.st_done": "आयात हो गई", +"sb.st_error": "विफल", +"sb.sample": "उदाहरण", +"sb.delete": "हटाएँ", +"sb.delete_all": "सब हटाएँ", +"sb.confirm": "{f} और उसका डेटा हटाएँ?", +"sb.confirm_all": "सभी कैप्चर फ़ाइलें और उनका डेटा हटाएँ?", +"sb.analyze": "विश्लेषण करें", +"sb.upload": "कैप्चर फ़ाइलें अपलोड करें…", +"sb.uploading": "{f} अपलोड हो रही है: {p}%", +"sb.failed": "अपलोड विफल", +"sb.too_big": "{f} {mb} MB से बड़ी है", +"sb.too_many": "अधिकतम {n} फ़ाइलें; पहले एक हटाएँ", +"sb.limits": "अधिकतम {n} फ़ाइलें, हर एक {mb} MB तक।", +"sb.formats": "फ़ॉर्मेट: .pcap और .pcapng (संपीड़ित नहीं)।", +"sb.empty": "अभी कोई कैप्चर फ़ाइल नहीं।", +"sb.banner": "ऑफ़लाइन विश्लेषण", +"sb.back": "लाइव डेटा पर लौटें", +"sb.range": "पूरा कैप्चर", +"sb.min": "{n} मिनट", +"sb.hours": "{n} घंटे", +"sb.not_capture": "{f} कैप्चर फ़ाइल नहीं है। Wireshark या tcpdump से सहेजी .pcap या .pcapng इस्तेमाल करें।" } diff --git a/internal/web/static/i18n/id.json b/internal/web/static/i18n/id.json index f1629ce..e9e15ed 100644 --- a/internal/web/static/i18n/id.json +++ b/internal/web/static/i18n/id.json @@ -376,5 +376,40 @@ "geo.map_none": "tanpa lalu lintas", "geo.data_from": "Negara dan jaringan:", "geo.map_from": "Peta:", -"td.both": "Kedua sisi" +"td.both": "Kedua sisi", +"nav.sandbox": "Analisis offline", +"sb.title": "File tangkapan", +"sb.sub": "Analisis file pcap terpisah dari data langsung", +"sb.explain": "Unggah tangkapan paket dari Wireshark atau tcpdump. File masuk ke basis data terpisah: data langsung, angkanya, dan temuannya tidak tersentuh. Analisis menampilkan file di semua halaman (ringkasan, Top 66, temuan, jalur, peta, catatan flow); Hapus menghapus file dan datanya.", +"sb.col_file": "File", +"sb.col_size": "Ukuran", +"sb.col_packets": "Paket", +"sb.col_flows": "Flow", +"sb.col_span": "Waktu", +"sb.col_status": "Status", +"sb.st_uploading": "mengunggah", +"sb.st_waiting": "menunggu", +"sb.st_importing": "mengimpor", +"sb.st_done": "Diimpor", +"sb.st_error": "Gagal", +"sb.sample": "contoh", +"sb.delete": "Hapus", +"sb.delete_all": "Hapus semua", +"sb.confirm": "Hapus {f} dan datanya?", +"sb.confirm_all": "Hapus semua file tangkapan dan datanya?", +"sb.analyze": "Analisis", +"sb.upload": "Unggah file tangkapan…", +"sb.uploading": "Mengunggah {f}: {p}%", +"sb.failed": "Gagal mengunggah", +"sb.too_big": "{f} lebih dari {mb} MB", +"sb.too_many": "Maksimal {n} file; hapus satu dulu", +"sb.limits": "Maksimal {n} file, masing-masing paling besar {mb} MB.", +"sb.formats": "Format: .pcap dan .pcapng (tidak dikompresi).", +"sb.empty": "Belum ada file tangkapan.", +"sb.banner": "Analisis offline", +"sb.back": "Kembali ke data langsung", +"sb.range": "seluruh tangkapan", +"sb.min": "{n} mnt", +"sb.hours": "{n} jam", +"sb.not_capture": "{f} bukan file tangkapan. Gunakan .pcap atau .pcapng yang disimpan Wireshark atau tcpdump." } diff --git a/internal/web/static/i18n/ja.json b/internal/web/static/i18n/ja.json index d86b926..ddf4e2a 100644 --- a/internal/web/static/i18n/ja.json +++ b/internal/web/static/i18n/ja.json @@ -376,5 +376,40 @@ "geo.map_none": "通信なし", "geo.data_from": "国・ネットワークのデータ:", "geo.map_from": "地図:", -"td.both": "両端" +"td.both": "両端", +"nav.sandbox": "オフライン分析", +"sb.title": "キャプチャファイル", +"sb.sub": "pcap ファイルをライブデータと分けて分析", +"sb.explain": "Wireshark や tcpdump のキャプチャファイルをアップロードします。別のデータベースに入るため、ライブデータや集計、検出結果には影響しません。「分析」を押すと全ページ(概要、Top 66、検出、フローパス、地図、フローレコード)にファイルの内容が表示されます。「削除」でファイルとデータを消します。", +"sb.col_file": "ファイル", +"sb.col_size": "サイズ", +"sb.col_packets": "パケット", +"sb.col_flows": "フロー", +"sb.col_span": "時間", +"sb.col_status": "状態", +"sb.st_uploading": "アップロード中", +"sb.st_waiting": "待機中", +"sb.st_importing": "取り込み中", +"sb.st_done": "取り込み済み", +"sb.st_error": "失敗", +"sb.sample": "サンプル", +"sb.delete": "削除", +"sb.delete_all": "すべて削除", +"sb.confirm": "{f} とそのデータを削除しますか?", +"sb.confirm_all": "すべてのキャプチャファイルとデータを削除しますか?", +"sb.analyze": "分析", +"sb.upload": "キャプチャファイルをアップロード…", +"sb.uploading": "{f} をアップロード中:{p}%", +"sb.failed": "アップロードに失敗しました", +"sb.too_big": "{f} は {mb} MB を超えています", +"sb.too_many": "ファイルは最大 {n} 個です。先に 1 つ削除してください", +"sb.limits": "最大 {n} ファイル、各 {mb} MB まで。", +"sb.formats": "形式:.pcap と .pcapng(圧縮ファイルは不可)。", +"sb.empty": "キャプチャファイルはまだありません。", +"sb.banner": "オフライン分析", +"sb.back": "ライブデータに戻る", +"sb.range": "キャプチャ全体", +"sb.min": "{n} 分", +"sb.hours": "{n} 時間", +"sb.not_capture": "{f} はキャプチャファイルではありません。Wireshark や tcpdump で保存した .pcap か .pcapng を使ってください。" } diff --git a/internal/web/static/i18n/ko.json b/internal/web/static/i18n/ko.json index 6ebdcd5..96f9503 100644 --- a/internal/web/static/i18n/ko.json +++ b/internal/web/static/i18n/ko.json @@ -376,5 +376,40 @@ "geo.map_none": "트래픽 없음", "geo.data_from": "국가·네트워크 데이터:", "geo.map_from": "지도:", -"td.both": "양쪽" +"td.both": "양쪽", +"nav.sandbox": "오프라인 분석", +"sb.title": "캡처 파일", +"sb.sub": "실시간 데이터와 따로 pcap 파일 분석", +"sb.explain": "Wireshark나 tcpdump 캡처 파일을 올립니다. 별도 데이터베이스에 들어가므로 실시간 데이터, 집계, 탐지 결과에는 영향이 없습니다. '분석'을 누르면 모든 페이지(개요, Top 66, 탐지, 플로 경로, 지도, 플로 레코드)에 파일 내용이 나오고, '삭제'는 파일과 데이터를 지웁니다.", +"sb.col_file": "파일", +"sb.col_size": "크기", +"sb.col_packets": "패킷", +"sb.col_flows": "플로", +"sb.col_span": "시간", +"sb.col_status": "상태", +"sb.st_uploading": "업로드 중", +"sb.st_waiting": "대기 중", +"sb.st_importing": "가져오는 중", +"sb.st_done": "가져옴", +"sb.st_error": "실패", +"sb.sample": "예제", +"sb.delete": "삭제", +"sb.delete_all": "모두 삭제", +"sb.confirm": "{f}와(과) 그 데이터를 삭제할까요?", +"sb.confirm_all": "모든 캡처 파일과 데이터를 삭제할까요?", +"sb.analyze": "분석", +"sb.upload": "캡처 파일 업로드…", +"sb.uploading": "{f} 업로드 중: {p}%", +"sb.failed": "업로드 실패", +"sb.too_big": "{f}이(가) {mb} MB를 넘습니다", +"sb.too_many": "파일은 최대 {n}개입니다. 먼저 하나를 삭제하세요", +"sb.limits": "최대 {n}개 파일, 각각 {mb} MB 이하.", +"sb.formats": "형식: .pcap, .pcapng(압축 파일 불가).", +"sb.empty": "아직 캡처 파일이 없습니다.", +"sb.banner": "오프라인 분석", +"sb.back": "실시간 데이터로 돌아가기", +"sb.range": "캡처 전체", +"sb.min": "{n}분", +"sb.hours": "{n}시간", +"sb.not_capture": "{f}은(는) 캡처 파일이 아닙니다. Wireshark나 tcpdump로 저장한 .pcap 또는 .pcapng를 쓰세요." } diff --git a/internal/web/static/i18n/pt.json b/internal/web/static/i18n/pt.json index a4eeead..6754c62 100644 --- a/internal/web/static/i18n/pt.json +++ b/internal/web/static/i18n/pt.json @@ -376,5 +376,40 @@ "geo.map_none": "sem tráfego", "geo.data_from": "Países e redes:", "geo.map_from": "Mapa:", -"td.both": "Ambas as pontas" +"td.both": "Ambas as pontas", +"nav.sandbox": "Análise offline", +"sb.title": "Arquivos de captura", +"sb.sub": "Analisar arquivos pcap separados dos dados ao vivo", +"sb.explain": "Envie capturas do Wireshark ou tcpdump. Elas vão para um banco separado: os dados ao vivo, seus números e detecções não são afetados. Analisar mostra os arquivos em todas as páginas (visão geral, Top 66, detecções, caminhos, mapa, registros); Excluir apaga os arquivos e seus dados.", +"sb.col_file": "Arquivo", +"sb.col_size": "Tamanho", +"sb.col_packets": "Pacotes", +"sb.col_flows": "Fluxos", +"sb.col_span": "Período", +"sb.col_status": "Status", +"sb.st_uploading": "enviando", +"sb.st_waiting": "na fila", +"sb.st_importing": "importando", +"sb.st_done": "Importado", +"sb.st_error": "Falhou", +"sb.sample": "exemplo", +"sb.delete": "Excluir", +"sb.delete_all": "Excluir tudo", +"sb.confirm": "Excluir {f} e seus dados?", +"sb.confirm_all": "Excluir todos os arquivos de captura e seus dados?", +"sb.analyze": "Analisar", +"sb.upload": "Enviar arquivos de captura…", +"sb.uploading": "Enviando {f}: {p}%", +"sb.failed": "Falha no envio", +"sb.too_big": "{f} passa de {mb} MB", +"sb.too_many": "No máximo {n} arquivos; exclua um primeiro", +"sb.limits": "Até {n} arquivos, cada um com no máximo {mb} MB.", +"sb.formats": "Formatos: .pcap e .pcapng (sem compressão).", +"sb.empty": "Nenhum arquivo de captura ainda.", +"sb.banner": "Análise offline", +"sb.back": "Voltar aos dados ao vivo", +"sb.range": "captura inteira", +"sb.min": "{n} min", +"sb.hours": "{n} h", +"sb.not_capture": "{f} não é um arquivo de captura. Use .pcap ou .pcapng salvo pelo Wireshark ou tcpdump." } diff --git a/internal/web/static/i18n/ru.json b/internal/web/static/i18n/ru.json index de529f8..b4a8f41 100644 --- a/internal/web/static/i18n/ru.json +++ b/internal/web/static/i18n/ru.json @@ -376,5 +376,40 @@ "geo.map_none": "нет трафика", "geo.data_from": "Страны и сети:", "geo.map_from": "Карта:", -"td.both": "Обе стороны" +"td.both": "Обе стороны", +"nav.sandbox": "Офлайн-анализ", +"sb.title": "Файлы захвата", +"sb.sub": "Анализ pcap-файлов отдельно от живых данных", +"sb.explain": "Загрузите захваты Wireshark или tcpdump. Они попадают в отдельную базу: живые данные, их статистика и находки не затрагиваются. «Анализ» показывает файлы на всех страницах (обзор, Top 66, находки, пути, карта, записи потоков); «Удалить» стирает файлы и их данные.", +"sb.col_file": "Файл", +"sb.col_size": "Размер", +"sb.col_packets": "Пакеты", +"sb.col_flows": "Потоки", +"sb.col_span": "Время", +"sb.col_status": "Состояние", +"sb.st_uploading": "загрузка", +"sb.st_waiting": "в очереди", +"sb.st_importing": "импорт", +"sb.st_done": "Импортирован", +"sb.st_error": "Ошибка", +"sb.sample": "пример", +"sb.delete": "Удалить", +"sb.delete_all": "Удалить всё", +"sb.confirm": "Удалить {f} и его данные?", +"sb.confirm_all": "Удалить все файлы захвата и их данные?", +"sb.analyze": "Анализ", +"sb.upload": "Загрузить файлы захвата…", +"sb.uploading": "Загрузка {f}: {p}%", +"sb.failed": "Не удалось загрузить", +"sb.too_big": "{f} больше {mb} МБ", +"sb.too_many": "Не больше {n} файлов; сначала удалите один", +"sb.limits": "До {n} файлов, каждый не больше {mb} МБ.", +"sb.formats": "Форматы: .pcap и .pcapng (без сжатия).", +"sb.empty": "Файлов захвата пока нет.", +"sb.banner": "Офлайн-анализ", +"sb.back": "Вернуться к живым данным", +"sb.range": "весь захват", +"sb.min": "{n} мин", +"sb.hours": "{n} ч", +"sb.not_capture": "{f} — не файл захвата. Используйте .pcap или .pcapng, сохранённый Wireshark или tcpdump." } diff --git a/internal/web/static/i18n/ur.json b/internal/web/static/i18n/ur.json index 5afa08f..7fb6a97 100644 --- a/internal/web/static/i18n/ur.json +++ b/internal/web/static/i18n/ur.json @@ -376,5 +376,40 @@ "geo.map_none": "ٹریفک نہیں", "geo.data_from": "ممالک اور نیٹ ورکس کا ڈیٹا:", "geo.map_from": "نقشہ:", -"td.both": "دونوں سرے" +"td.both": "دونوں سرے", +"nav.sandbox": "آف لائن تجزیہ", +"sb.title": "کیپچر فائلیں", +"sb.sub": "لائیو ڈیٹا سے الگ pcap فائلوں کا تجزیہ", +"sb.explain": "Wireshark یا tcpdump کی کیپچر فائلیں اپ لوڈ کریں۔ یہ الگ ڈیٹابیس میں جاتی ہیں: لائیو ڈیٹا، اس کے اعداد اور نتائج متاثر نہیں ہوتے۔ «تجزیہ کریں» تمام صفحات (جائزہ، Top 66، نتائج، فلو راستے، نقشہ، فلو ریکارڈز) پر فائلیں دکھاتا ہے؛ «حذف کریں» فائلیں اور ان کا ڈیٹا مٹا دیتا ہے۔", +"sb.col_file": "فائل", +"sb.col_size": "سائز", +"sb.col_packets": "پیکٹ", +"sb.col_flows": "فلو", +"sb.col_span": "وقت", +"sb.col_status": "حالت", +"sb.st_uploading": "اپ لوڈ ہو رہی ہے", +"sb.st_waiting": "قطار میں", +"sb.st_importing": "امپورٹ ہو رہی ہے", +"sb.st_done": "امپورٹ ہو گئی", +"sb.st_error": "ناکام", +"sb.sample": "مثال", +"sb.delete": "حذف کریں", +"sb.delete_all": "سب حذف کریں", +"sb.confirm": "{f} اور اس کا ڈیٹا حذف کریں؟", +"sb.confirm_all": "تمام کیپچر فائلیں اور ان کا ڈیٹا حذف کریں؟", +"sb.analyze": "تجزیہ کریں", +"sb.upload": "کیپچر فائلیں اپ لوڈ کریں…", +"sb.uploading": "{f} اپ لوڈ ہو رہی ہے: {p}%", +"sb.failed": "اپ لوڈ ناکام", +"sb.too_big": "{f} {mb} MB سے بڑی ہے", +"sb.too_many": "زیادہ سے زیادہ {n} فائلیں؛ پہلے ایک حذف کریں", +"sb.limits": "زیادہ سے زیادہ {n} فائلیں، ہر ایک {mb} MB تک۔", +"sb.formats": "فارمیٹ: ‎.pcap اور ‎.pcapng (کمپریس شدہ نہیں)۔", +"sb.empty": "ابھی کوئی کیپچر فائل نہیں۔", +"sb.banner": "آف لائن تجزیہ", +"sb.back": "لائیو ڈیٹا پر واپس", +"sb.range": "پورا کیپچر", +"sb.min": "{n} منٹ", +"sb.hours": "{n} گھنٹے", +"sb.not_capture": "{f} کیپچر فائل نہیں ہے۔ Wireshark یا tcpdump سے محفوظ ‎.pcap یا ‎.pcapng استعمال کریں۔" } diff --git a/internal/web/static/i18n/zh.json b/internal/web/static/i18n/zh.json index 26cb958..4adec8f 100644 --- a/internal/web/static/i18n/zh.json +++ b/internal/web/static/i18n/zh.json @@ -376,5 +376,40 @@ "geo.map_none": "无流量", "geo.data_from": "国家和运营商数据:", "geo.map_from": "地图:", -"td.both": "两端" +"td.both": "两端", +"nav.sandbox": "离线分析", +"sb.title": "抓包文件", +"sb.sub": "在实时数据之外分析 pcap 文件", +"sb.explain": "上传 Wireshark 或 tcpdump 的抓包文件。它们进入一个单独的数据库,不影响实时数据、统计和发现。点“分析”后,所有页面(概览、Top 66、发现、流向、地图、流记录)都显示这些文件的内容;“删除”会删除文件及其数据。", +"sb.col_file": "文件", +"sb.col_size": "大小", +"sb.col_packets": "包数", +"sb.col_flows": "流数", +"sb.col_span": "时间", +"sb.col_status": "状态", +"sb.st_uploading": "上传中", +"sb.st_waiting": "排队中", +"sb.st_importing": "导入中", +"sb.st_done": "已导入", +"sb.st_error": "失败", +"sb.sample": "示例", +"sb.delete": "删除", +"sb.delete_all": "全部删除", +"sb.confirm": "删除 {f} 及其数据?", +"sb.confirm_all": "删除所有抓包文件及其数据?", +"sb.analyze": "分析", +"sb.upload": "上传抓包文件…", +"sb.uploading": "正在上传 {f}:{p}%", +"sb.failed": "上传失败", +"sb.too_big": "{f} 超过 {mb} MB", +"sb.too_many": "最多 {n} 个文件,请先删除一个", +"sb.limits": "最多 {n} 个文件,每个不超过 {mb} MB。", +"sb.formats": "格式:.pcap 和 .pcapng(不支持压缩包)。", +"sb.empty": "还没有抓包文件。", +"sb.banner": "离线分析", +"sb.back": "返回实时数据", +"sb.range": "整个抓包", +"sb.min": "{n} 分钟", +"sb.hours": "{n} 小时", +"sb.not_capture": "{f} 不是抓包文件。请使用 Wireshark 或 tcpdump 保存的 .pcap 或 .pcapng。" } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 055513f..8614e56 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -22,6 +22,7 @@
+
@@ -43,6 +44,7 @@

+
@@ -56,6 +58,7 @@

+