portune 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
portune/portune.py ADDED
@@ -0,0 +1,1745 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ re-port.py - A utility to check port accessibility and generate HTML reports.
5
+
6
+ This script performs port scanning across multiple hosts and generates detailed reports
7
+ in both console and HTML formats. It supports parallel scanning, DNS resolution,
8
+ ping checks, and email notifications.
9
+
10
+ Author: joknarf
11
+ License: MIT
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ # Standard library imports
17
+ import os
18
+ import sys
19
+ import platform
20
+ import argparse
21
+ import socket
22
+ import threading
23
+ import time
24
+ import subprocess
25
+ from concurrent.futures import ThreadPoolExecutor, as_completed
26
+ from collections import defaultdict
27
+ from html import escape
28
+ import smtplib
29
+ from email.mime.text import MIMEText
30
+ from email.mime.multipart import MIMEMultipart
31
+ from email.mime.application import MIMEApplication
32
+ from typing import List, Dict, Tuple, Any, Optional, Union
33
+
34
+ # Constants and global variables
35
+
36
+ # Use system ping command based on the OS
37
+ # as raw socket ICMP ping requires privileges
38
+ OS = platform.system().lower()
39
+ if OS == "windows":
40
+ PING = "ping -n 1 -w {timeoutms} {ip}"
41
+ elif OS == "darwin": # macOS
42
+ PING = "ping -c 1 -t {timeout} {ip}"
43
+ elif OS == "sunos": # SunOS
44
+ PING = "ping {ip} {timeout}"
45
+ elif OS == "aix": # IBM AIX
46
+ PING = "ping -c 1 -w {timeout} {ip}"
47
+ elif OS.startswith("hp-ux"): # HP-UX 11.11+
48
+ PING = "ping -n 1 -m {timeout} {ip}"
49
+ else:
50
+ PING = "ping -c 1 -W {timeout} {ip}"
51
+
52
+
53
+
54
+ ICON = "data:image/svg+xml,%3Csvg height='200px' width='200px' version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' viewBox='0 0 508 508' xml:space='preserve' fill='%23000000'%3E%3Cg id='SVGRepo_bgCarrier' stroke-width='0'%3E%3C/g%3E%3Cg id='SVGRepo_tracerCarrier' stroke-linecap='round' stroke-linejoin='round'%3E%3C/g%3E%3Cg id='SVGRepo_iconCarrier'%3E%3Ccircle style='fill:%23ffbb06;' cx='254' cy='254' r='254'%3E%3C/circle%3E%3Cg%3E%3Ccircle style='fill:%234c4c4c;' cx='399.6' cy='252' r='55.2'%3E%3C/circle%3E%3Ccircle style='fill:%234c4c4c;' cx='150' cy='356' r='55.2'%3E%3C/circle%3E%3Ccircle style='fill:%234c4c4c;' cx='178.4' cy='127.2' r='55.2'%3E%3C/circle%3E%3C/g%3E%3Cpath style='fill:%23000000;' d='M301.2,282.4l15.6,1.2l6.4-19.2l-13.2-8c0.4-5.6,0-11.2-1.2-16.4l12-10l-9.2-18l-15.2,3.6 c-3.6-4-7.6-8-12.4-10.8l1.2-15.6l-19.2-6.4l-8,13.2c-5.6-0.4-11.2,0-16.4,1.2l-10-12l-18,8.8l3.6,15.2c-4,3.6-8,7.6-10.8,12.4 l-15.6-1.2l-6.4,19.2l13.2,8.4c-0.4,5.6,0,11.2,1.2,16.4l-12,10l9.2,18l15.2-3.6c3.6,4,7.6,8,12.4,10.8l-1.6,15.2l19.2,6.4l8.4-13.2 c5.6,0.4,11.2,0,16.4-1.2l10,12l18-8.8l-3.6-15.2C294.4,291.2,298.4,287.2,301.2,282.4z M242.4,286c-18.8-6.4-28.8-26.4-22.8-45.2 c6.4-18.8,26.8-29.2,45.6-22.8c18.8,6.4,28.8,26.4,22.8,45.2C281.6,282,261.2,292.4,242.4,286z'%3E%3C/path%3E%3Cpath style='fill:%23324A5E;' d='M380.4,304c-20.4,50-69.6,85.2-126.8,85.2c-18.8,0-36.8-4-53.6-10.8c-2.4,5.6-5.6,10.4-9.6,14.8 c19.2,8.8,40.8,13.6,63.2,13.6c65.6,0,122-41.2,144.4-99.2C392,307.2,386,306,380.4,304z M132,157.2c-20.4,26-32.8,59.2-32.8,94.8 c0,22.4,4.8,43.6,13.6,62.8c4.4-4,9.2-7.2,14.8-9.6c-6.8-16.4-10.8-34.4-10.8-53.2c0-30.4,10-58.8,27.2-81.6 C139.2,166.8,135.2,162.4,132,157.2z M253.6,97.6c-9.2,0-18.4,0.8-27.6,2.4c2.8,5.2,5.2,10.8,6.4,16.8c6.8-1.2,14-1.6,21.2-1.6 c57.2,0,106.4,35.2,126.8,85.2c5.6-2,11.2-3.2,17.2-3.2C375.6,138.8,319.6,97.6,253.6,97.6z'%3E%3C/path%3E%3Cg%3E%3Ccircle style='fill:%23FF7058;' cx='399.6' cy='252' r='28.4'%3E%3C/circle%3E%3Ccircle style='fill:%23FF7058;' cx='150' cy='356' r='28.4'%3E%3C/circle%3E%3Ccircle style='fill:%23FF7058;' cx='178.4' cy='127.2' r='28.4'%3E%3C/circle%3E%3C/g%3E%3C/g%3E%3C/svg%3E"
55
+ XL='data:image/svg+xml,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M12.5535 16.5061C12.4114 16.6615 12.2106 16.75 12 16.75C11.7894 16.75 11.5886 16.6615 11.4465 16.5061L7.44648 12.1311C7.16698 11.8254 7.18822 11.351 7.49392 11.0715C7.79963 10.792 8.27402 10.8132 8.55352 11.1189L11.25 14.0682V3C11.25 2.58579 11.5858 2.25 12 2.25C12.4142 2.25 12.75 2.58579 12.75 3V14.0682L15.4465 11.1189C15.726 10.8132 16.2004 10.792 16.5061 11.0715C16.8118 11.351 16.833 11.8254 16.5535 12.1311L12.5535 16.5061Z" fill="%23eee"></path><path d="M3.75 15C3.75 14.5858 3.41422 14.25 3 14.25C2.58579 14.25 2.25 14.5858 2.25 15V15.0549C2.24998 16.4225 2.24996 17.5248 2.36652 18.3918C2.48754 19.2919 2.74643 20.0497 3.34835 20.6516C3.95027 21.2536 4.70814 21.5125 5.60825 21.6335C6.47522 21.75 7.57754 21.75 8.94513 21.75H15.0549C16.4225 21.75 17.5248 21.75 18.3918 21.6335C19.2919 21.5125 20.0497 21.2536 20.6517 20.6516C21.2536 20.0497 21.5125 19.2919 21.6335 18.3918C21.75 17.5248 21.75 16.4225 21.75 15.0549V15C21.75 14.5858 21.4142 14.25 21 14.25C20.5858 14.25 20.25 14.5858 20.25 15C20.25 16.4354 20.2484 17.4365 20.1469 18.1919C20.0482 18.9257 19.8678 19.3142 19.591 19.591C19.3142 19.8678 18.9257 20.0482 18.1919 20.1469C17.4365 20.2484 16.4354 20.25 15 20.25H9C7.56459 20.25 6.56347 20.2484 5.80812 20.1469C5.07435 20.0482 4.68577 19.8678 4.40901 19.591C4.13225 19.3142 3.9518 18.9257 3.85315 18.1919C3.75159 17.4365 3.75 16.4354 3.75 15Z" fill="%23eee"></path></g></svg>'
56
+ CSS="""
57
+ <style>
58
+ body {
59
+ font-family: Arial, sans-serif;
60
+ overflow: auto;
61
+ }
62
+ .hidden {
63
+ display: none;
64
+ }
65
+ .icon {
66
+ border-radius: 25px;
67
+ background-color: #444;
68
+ color: #eee;
69
+ padding: 10px 20px 2px 6px;
70
+ background-image: url("{ICON}");
71
+ background-repeat: no-repeat no-repeat;
72
+ background-position: 12px 9px;
73
+ background-size: 24px;
74
+ height: 30px;
75
+ display: inline-block;
76
+ text-indent: 40px;
77
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.60);
78
+ }
79
+ div {
80
+ scrollbar-width: thin;
81
+ }
82
+ .blue, .green, .red {
83
+ color: white;
84
+ border-radius: 15px;
85
+ text-align: center;
86
+ font-weight: 700;
87
+ display: inline-block;
88
+ padding: 2px 5px;
89
+ }
90
+ .green { background-color: #4CAF50; }
91
+ .red { background-color: #f44336; }
92
+ .blue { background-color: #2196F3; }
93
+ .status {
94
+ width: 150px;
95
+ }
96
+ .ping {
97
+ width: 100px;
98
+ }
99
+ .pct {
100
+ width: 60px;
101
+ text-align: right;
102
+ padding-right: 8px;
103
+ }
104
+ .desc {
105
+ text-overflow: ellipsis;
106
+ max-width: 200px;
107
+ white-space: nowrap;
108
+ overflow: hidden;
109
+ }
110
+ .desc:hover {
111
+ overflow: visible;
112
+ white-space: normal;
113
+ }
114
+ .table-container {
115
+ height: 90%;
116
+ overflow-y: auto;
117
+ position: relative;
118
+ border-radius: 10px;
119
+ border: 1px solid #aaa;
120
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.40);
121
+ margin-bottom: 10px;
122
+ }
123
+ #result-container {
124
+ max-height: 800px; /* calc(100vh - 250px); */
125
+ }
126
+ table {
127
+ width: 100%;
128
+ border-collapse: collapse;
129
+ }
130
+ div.table-container table tr:hover {
131
+ background-color: #f1f1f1;
132
+ }
133
+ th, td {
134
+ text-align: left;
135
+ border-bottom: 1px solid #ddd;
136
+ white-space: nowrap;
137
+ }
138
+
139
+ td {
140
+ padding: 4px 7px;; /* 2px 7px; */
141
+ }
142
+ th {
143
+ padding: 7px;
144
+ background-color: #444;
145
+ color: #eee;
146
+ position: sticky;
147
+ top: 0;
148
+ z-index: 1;
149
+ vertical-align: top;
150
+ }
151
+ .th-content {
152
+ display: flex;
153
+ align-items: center;
154
+ gap: 5px;
155
+ white-space: nowrap;
156
+ font-weight: 600;
157
+ }
158
+ .sort-btn {
159
+ color: #aaa;
160
+ font-size: 10px;
161
+ user-select: none;
162
+ display: inline-block;
163
+ width: 7px;
164
+ text-align: center;
165
+ flex-shrink: 0;
166
+ }
167
+ .sort-btn[data-sort-order="asc"] {
168
+ color: #5a5;
169
+ }
170
+ .sort-btn[data-sort-order="desc"] {
171
+ color: #5a5;
172
+ }
173
+ .column-filter {
174
+ display: block;
175
+ width: 90%;
176
+ max-width: 300px;
177
+ margin: 5px 0px 0px 0px;
178
+ padding: 2px 5px;
179
+ text-indent: 5px;
180
+ border: 1px solid #666;
181
+ border-radius: 15px;
182
+ font-size: 12px;
183
+ background: #444;
184
+ color: #eee
185
+ }
186
+ .column-filter:focus {
187
+ outline: none;
188
+ border-color: #666;
189
+ background: #ddd;
190
+ color: #222;
191
+ }
192
+ .column-filter:focus::placeholder {
193
+ color: transparent;
194
+ }
195
+ .column-filter::placeholder {
196
+ font-size: 12px;
197
+ color: #888;
198
+ }
199
+ .copy-icon {
200
+ cursor: pointer;
201
+ }
202
+ .system-font {
203
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
204
+ }
205
+ .copied {
206
+ color: green;
207
+ margin-left: 5px;
208
+ }
209
+ .title-icon {
210
+ display: inline;
211
+ height: 30px;
212
+ background-image: url("/static/images/favicon.svg");
213
+ background-size: 30px 30px;
214
+ background-repeat: no-repeat;
215
+ background-position-y: -1px;
216
+ vertical-align: bottom;
217
+ padding-left: 35px;
218
+ }
219
+ .copy_clip {
220
+ padding-right: 20px;
221
+ background-repeat: no-repeat;
222
+ background-position: right top;
223
+ background-size: 20px 12px;
224
+ white-space: nowrap;
225
+ }
226
+ .copy_clip:hover {
227
+ cursor: pointer;
228
+ background-image: url("/static/images/copy.svg");
229
+ }
230
+ .copy_clip_ok, .copy_clip_ok:hover {
231
+ background-image: url("/static/images/copy_ok.svg");
232
+ }
233
+
234
+ .row-count {
235
+ display: inline-block;
236
+ font-size: 11px;
237
+ font-weight: normal;
238
+ border: 1px solid #aaa;
239
+ border-radius: 17px;
240
+ /* min-width: 17px; */
241
+ text-align: center;
242
+ /* text-indent: 14px;*/
243
+ padding: 0px 18px 0px 4px;
244
+ margin-left: auto;
245
+ cursor: pointer;
246
+ background-image: url('{XL}');
247
+ background-repeat: no-repeat;
248
+ background-position: right 4px center;
249
+ background-size: 12px 12px;
250
+ }
251
+
252
+ </style>
253
+ """.replace("{ICON}", ICON).replace("{XL}", XL)
254
+ JS="""
255
+ <script src=https://unpkg.com/exceljs@4.1.1/dist/exceljs.min.js></script>
256
+ <script>
257
+ function initTableFilters(table) {
258
+ const headers = table.querySelectorAll('thead th');
259
+ headers.forEach((header, index) => {
260
+ if (table==commandsTable) {
261
+ const contentSpan = document.createElement('span');
262
+ contentSpan.className = 'th-content';
263
+
264
+ // Add sort button first
265
+ const sortBtn = document.createElement('span');
266
+ sortBtn.className = 'sort-btn';
267
+ sortBtn.innerHTML = '▲' //'&#11014;'; // Unicode for sort icon ▼
268
+ sortBtn.style.cursor = 'pointer';
269
+ sortBtn.setAttribute('data-sort-order', '');
270
+ sortBtn.onclick = () => toggleSort(table, index, sortBtn);
271
+
272
+ const titleSpan = document.createElement('span');
273
+ titleSpan.className = 'th-title';
274
+ titleSpan.innerHTML = header.innerHTML;
275
+
276
+ // Move existing elements into the content span
277
+ //while (header.firstChild) {
278
+ // contentSpan.appendChild(header.firstChild);
279
+ //}
280
+
281
+ // Add sort button at the beginning
282
+ contentSpan.appendChild(sortBtn);
283
+ contentSpan.appendChild(titleSpan);
284
+
285
+ //contentSpan.insertBefore(sortBtn, contentSpan.firstChild);
286
+
287
+ // Add export button and row counter for last column
288
+ if (index === headers.length - 1) {
289
+ // Add row counter
290
+ const rowCount = document.createElement('span');
291
+ rowCount.className = 'row-count system-font';
292
+ rowCount.title = 'Export to Excel';
293
+ rowCount.onclick = () => exportToExcel(table);
294
+ contentSpan.appendChild(rowCount);
295
+ }
296
+
297
+ header.innerHTML = '';
298
+ header.appendChild(contentSpan);
299
+
300
+ // Add filter input
301
+ const input = document.createElement('input');
302
+ input.type = 'search';
303
+ input.className = `column-filter input-${index}`;
304
+ input.placeholder = '\\uD83D\\uDD0E\\uFE0E';
305
+ input.addEventListener('input', () => applyFilters(table));
306
+ header.appendChild(input);
307
+ }
308
+ });
309
+ // Initialize row count
310
+ updateRowCount(table, table.querySelectorAll('tbody tr:not(.hidden)').length);
311
+ }
312
+
313
+ function updateRowCount(table, count) {
314
+ const rowCount = table.querySelector('.row-count');
315
+ const totalRows = table.querySelectorAll('tbody tr').length;
316
+ if (rowCount) {
317
+ rowCount.innerHTML = ` ${count}/${totalRows}`; // &#129095;🡇 not working macOS
318
+ }
319
+ }
320
+
321
+ function toggleSort(table, colIndex, sortBtn) {
322
+ // Reset other sort buttons
323
+ table.querySelectorAll('.sort-btn').forEach(btn => {
324
+ if (btn !== sortBtn) {
325
+ btn.setAttribute('data-sort-order', '');
326
+ btn.innerHTML = '▲'; //'&#11014;';
327
+ }
328
+ });
329
+
330
+ // Toggle sort order
331
+ const currentOrder = sortBtn.getAttribute('data-sort-order');
332
+ let newOrder = 'asc';
333
+ if (currentOrder === 'asc') {
334
+ newOrder = 'desc';
335
+ sortBtn.innerHTML = '▼'; //'&#11015;';
336
+ } else if (currentOrder === 'desc') {
337
+ newOrder = '';
338
+ sortBtn.innerHTML = '▲'; //'&#11014;';
339
+ } else {
340
+ sortBtn.innerHTML = '▲'; //'&#11014;';
341
+ }
342
+ sortBtn.setAttribute('data-sort-order', newOrder);
343
+ sortBtn.setAttribute('data-col-index', colIndex); // Store column index on the button
344
+ applyFilters(table);
345
+ }
346
+
347
+ function applyFilters(table) {
348
+ table.classList.add('hidden');
349
+ const rows = Array.from(table.querySelectorAll('tbody tr'));
350
+ const filters = Array.from(table.querySelectorAll('.column-filter'))
351
+ .map(filter => ({
352
+ value: filter.value.toLowerCase(),
353
+ index: filter.parentElement.cellIndex,
354
+ regexp: filter.value ? (() => {
355
+ try { return new RegExp(filter.value, 'i'); }
356
+ catch(e) { return null; }
357
+ })() : null
358
+ }));
359
+
360
+ // First apply filters
361
+ const filteredRows = rows.filter(row => {
362
+ // If no filters are active, show all rows
363
+ //if (filters.every(f => !f.value)) {
364
+ // row.classList.remove('hidden');
365
+ // return true;
366
+ //}
367
+ const cells = row.cells;
368
+ const shouldShow = !filters.some(filter => {
369
+ if (!filter.value) return false;
370
+ const cellText = cells[filter.index]?.innerText || '';
371
+ if (filter.regexp) return !filter.regexp.test(cellText);
372
+ return !cellText.toLowerCase().includes(filter.value);
373
+ });
374
+ if (shouldShow) {
375
+ row.classList.remove('hidden');
376
+ } else {
377
+ row.classList.add('hidden');
378
+ }
379
+ return shouldShow;
380
+ });
381
+
382
+ // Update row count
383
+ updateRowCount(table, filteredRows.length);
384
+
385
+ // Then apply sorting if active
386
+ const sortBtn = table.querySelector('.sort-btn[data-sort-order]:not([data-sort-order=""])');
387
+ if (sortBtn) {
388
+ const colIndex = parseInt(sortBtn.getAttribute('data-col-index'));
389
+ const sortOrder = sortBtn.getAttribute('data-sort-order');
390
+
391
+ filteredRows.sort((a, b) => {
392
+ const aVal = a.cells[colIndex]?.innerText.trim() || '';
393
+ const bVal = b.cells[colIndex]?.innerText.trim() || '';
394
+
395
+ // Check if both values are numeric
396
+ const aNum = !isNaN(aVal) && !isNaN(parseFloat(aVal));
397
+ const bNum = !isNaN(bVal) && !isNaN(parseFloat(bVal));
398
+
399
+ if (aNum && bNum) {
400
+ // Numeric comparison
401
+ return sortOrder === 'asc'
402
+ ? parseFloat(aVal) - parseFloat(bVal)
403
+ : parseFloat(bVal) - parseFloat(aVal);
404
+ }
405
+
406
+ // Fallback to string comparison
407
+ if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
408
+ if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
409
+ return 0;
410
+ });
411
+
412
+ // Reorder visible rows
413
+ const tbody = table.querySelector('tbody');
414
+ filteredRows.forEach(row => tbody.appendChild(row));
415
+ }
416
+ table.classList.remove('hidden');
417
+ }
418
+
419
+ function processHtmlContent(element) {
420
+ function processLi(li, level = 0) {
421
+ const indent = ' '.repeat(level);
422
+ const items = [];
423
+
424
+ // Extraire le texte direct (avant sous-liste)
425
+ const textContent = Array.from(li.childNodes)
426
+ .filter(node => node.nodeType === Node.TEXT_NODE)
427
+ .map(node => node.textContent.trim())
428
+ .join(' ')
429
+ .replace(/\\s+/g, ' ')
430
+ .trim();
431
+
432
+ if (textContent) {
433
+ items.push(indent + '• ' + textContent);
434
+ }
435
+
436
+ // Traiter récursivement les sous-listes
437
+ const subLists = li.querySelectorAll(':scope > ul > li');
438
+ if (subLists.length) {
439
+ for (const subLi of subLists) {
440
+ items.push(...processLi(subLi, level + 1));
441
+ }
442
+ }
443
+
444
+ return items;
445
+ }
446
+
447
+ const list = element.querySelector('ul');
448
+ if (list) {
449
+ const items = Array.from(list.children)
450
+ .filter(el => el.tagName === 'LI')
451
+ .map(li => processLi(li))
452
+ .flat();
453
+ return items.join('\\n');
454
+ }
455
+ const text = element.textContent.replace(/\\s+/g, ' ').trim();
456
+ // Return object with type info if it's a number
457
+ if (/^\\d+$/.test(text)) {
458
+ return { value: parseInt(text, 10), type: 'integer' };
459
+ }
460
+ return text;
461
+ }
462
+
463
+ function exportToExcel(table, fileNamePrefix = 'export') {
464
+ const workbook = new ExcelJS.Workbook();
465
+ const worksheet = workbook.addWorksheet('Sheet1', {
466
+ views: [{ state: 'frozen', xSplit: 0, ySplit: 1 }]
467
+ });
468
+
469
+ // Get headers and data
470
+ const headers = Array.from(table.querySelectorAll('thead th'))
471
+ .map(th => th.querySelector('.th-title')?.textContent.trim() || '');
472
+
473
+ // Get data rows with type information
474
+ const rows = Array.from(table.querySelectorAll('tbody tr'))
475
+ .filter(row => ! row.classList.contains('hidden'))
476
+ .map(row =>
477
+ Array.from(row.cells)
478
+ .map(cell => {
479
+ const content = processHtmlContent(cell);
480
+ if (content && typeof content === 'object' && content.type === 'integer') {
481
+ return content.value; // Numbers will be handled as numbers by ExcelJS
482
+ }
483
+ return (typeof content === 'string' ? content : content.toString())
484
+ })
485
+ );
486
+
487
+ // Calculate optimal column widths based on content
488
+ const columnWidths = headers.map((header, colIndex) => {
489
+ // Start with header width
490
+ let maxWidth = header.length;
491
+
492
+ // Check width needed for each row's cell in this column
493
+ rows.forEach(row => {
494
+ const cellContent = row[colIndex];
495
+ if (cellContent === null || cellContent === undefined) return;
496
+
497
+ // Convert numbers to string for width calculation
498
+ const contentStr = cellContent.toString();
499
+ // Get the longest line in multiline content
500
+ const lines = contentStr.split('\\n');
501
+ const longestLine = Math.max(...lines.map(line => line.length));
502
+ maxWidth = Math.max(maxWidth, longestLine);
503
+ });
504
+
505
+ // Add some padding and ensure minimum/maximum widths
506
+ return { width: Math.min(Math.max(maxWidth + 5, 10), 100) };
507
+ });
508
+
509
+ // Define columns with calculated widths
510
+ worksheet.columns = headers.map((header, index) => ({
511
+ header: header,
512
+ key: header,
513
+ width: columnWidths[index].width
514
+ }));
515
+
516
+ // Add data rows
517
+ rows.forEach(rowData => {
518
+ const row = worksheet.addRow(rowData);
519
+ row.alignment = { vertical: 'top', wrapText: true };
520
+
521
+ // Set row height based on content, handling both strings and numbers
522
+ // const maxLines = Math.max(...rowData.map(cell => {
523
+ // if (cell === null || cell === undefined) return 1;
524
+ // const str = cell.toString();
525
+ // return (str.match(/\\n/g) || []).length + 1;
526
+ // }));
527
+ // row.height = Math.max(20, maxLines * 15);
528
+ });
529
+
530
+ // Style header row
531
+ const headerRow = worksheet.getRow(1);
532
+ // headerRow.font = { bold: true };
533
+ // headerRow.alignment = { vertical: 'middle', horizontal: 'left' };
534
+ // headerRow.height = 20;
535
+
536
+ // Add table after all rows are defined
537
+ worksheet.addTable({
538
+ name: 'DataTable',
539
+ ref: 'A1',
540
+ headerRow: true,
541
+ totalsRow: false,
542
+ style: {
543
+ theme: 'TableStyleMedium2',
544
+ showRowStripes: true,
545
+ },
546
+ columns: headers.map(h => ({
547
+ name: h,
548
+ filterButton: true
549
+ })),
550
+ rows: rows
551
+ });
552
+
553
+ // Save file
554
+ workbook.xlsx.writeBuffer().then(buffer => {
555
+ const blob = new Blob([buffer], {
556
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
557
+ });
558
+ const url = window.URL.createObjectURL(blob);
559
+ const a = document.createElement('a');
560
+ a.href = url;
561
+ a.download = fileNamePrefix + '_' + new Date().toISOString().slice(0,10) + '.xlsx';
562
+ a.click();
563
+ window.URL.revokeObjectURL(url);
564
+ });
565
+ }
566
+
567
+ let commandsTable = document.querySelector('#commandTable');
568
+ document.addEventListener('DOMContentLoaded', () => {
569
+ if (commandsTable) initTableFilters(commandsTable);
570
+ document.querySelector('.input-4').value = '{FILTER}';
571
+ });
572
+ </script>
573
+ """
574
+ # Get system information
575
+ HOSTNAME = socket.gethostname()
576
+
577
+ def get_local_ip():
578
+ try:
579
+ # Get all IP addresses associated with the hostname
580
+ ip_list = socket.gethostbyname_ex(HOSTNAME)[2]
581
+ # Filter out the loopback address
582
+ local_ips = [ip for ip in ip_list if not ip.startswith("127.")]
583
+ return local_ips[0] if local_ips else ""
584
+ except Exception:
585
+ return ""
586
+
587
+ MY_IP = get_local_ip()
588
+
589
+ class ProgressBar:
590
+ """A progress bar for displaying task completion status in the terminal.
591
+
592
+ The progress bar shows completion percentage, task count, and processing speed
593
+ in a dynamic, updating display.
594
+
595
+ Attributes:
596
+ total (int): Total number of tasks to be completed
597
+ prefix (str): Text to display before the progress bar
598
+ length (int): Length of the progress bar in characters
599
+ current (int): Current number of completed tasks
600
+ start_time (float): Time when the progress bar was initialized
601
+ """
602
+
603
+ def __init__(self, total: int, prefix: str = 'Progress:', length: int = 50) -> None:
604
+ """Initialize the progress bar.
605
+
606
+ Args:
607
+ total: Total number of tasks to track
608
+ prefix: Text to display before the progress bar
609
+ length: Visual length of the progress bar in characters
610
+ """
611
+ self.total = total
612
+ self.prefix = prefix
613
+ self.length = length
614
+ self.current = 0
615
+ self.start_time = time.time()
616
+
617
+ def update(self, increment: int = 1) -> None:
618
+ """Update the progress bar by incrementing the completed task count.
619
+
620
+ Updates the display to show current progress, percentage complete,
621
+ and processing speed. Automatically handles terminal output formatting.
622
+
623
+ Args:
624
+ increment: Number of tasks completed in this update
625
+ """
626
+ self.current += increment
627
+ filled_length = int(self.length * self.current / self.total)
628
+ bar = '█' * filled_length + '-' * (self.length - filled_length)
629
+ percentage = f"{100 * self.current / self.total:.1f}%"
630
+ elapsed_time = time.time() - self.start_time
631
+ speed = self.current / elapsed_time if elapsed_time > 0 else 0
632
+
633
+
634
+ # Create the progress message
635
+ progress_msg = f'{self.prefix} |{bar}| {percentage} ({self.current}/{self.total}) [{speed:.1f} ports/s]'
636
+ # Add padding to ensure old content is cleared
637
+ try:
638
+ col = os.get_terminal_size(2).columns
639
+ except OSError:
640
+ col = 80
641
+ padding = ' ' * (col - len(progress_msg) - 1)
642
+
643
+ sys.stderr.write('\r' + progress_msg[:col - 1] + padding)
644
+ sys.stderr.flush()
645
+ if self.current == self.total:
646
+ sys.stderr.write('\n')
647
+ sys.stderr.flush()
648
+
649
+ def parse_input_file(filename: str, info_command: Optional[str] = None) -> List[Tuple[str, List[int]]]:
650
+ """Parse the input file containing host and port information.
651
+
652
+ Reads a text file where each line contains a hostname and optionally a list of ports.
653
+ Lines starting with # are treated as comments and ignored.
654
+ If a line contains only a hostname, port 22 is assumed.
655
+ Port lists can be comma-separated.
656
+
657
+ Args:
658
+ filename: Path to the input file
659
+
660
+ Returns:
661
+ A list of tuples, each containing:
662
+ - hostname (str): The target hostname or IP address
663
+ - ports (List[int]): List of ports to scan for that host
664
+ - desc (str): Optional description for the host
665
+
666
+ Examples:
667
+ Input file format:
668
+ # Comment line
669
+ host1 22,80,443
670
+ host2
671
+ host3 8080
672
+
673
+ Will return:
674
+ [
675
+ ('host1', [22, 80, 443]),
676
+ ('host2', [22]),
677
+ ('host3', [8080])
678
+ ]
679
+ """
680
+ host_dict = {}
681
+ with open(filename, 'r') as f:
682
+ for line in f:
683
+ line = line.strip()
684
+ if not line or line.startswith('#'):
685
+ continue
686
+ words = line.split()
687
+ fqdn = words[0].lower()
688
+ ports = words[1] if len(words) > 1 else '22'
689
+ port_list = [int(p) for p in ports.split(',')]
690
+ desc = ' '.join(words[2:]).strip().split('|') if len(words) > 2 else []
691
+ if fqdn in host_dict:
692
+ existing_ports, existing_desc = host_dict[fqdn]
693
+ host_dict[fqdn] = (list(set(existing_ports + port_list)), existing_desc or desc)
694
+ else:
695
+ host_dict[fqdn] = (port_list, desc)
696
+ desc_titles = []
697
+ if info_command:
698
+ res = subprocess.run(info_command, shell=True, input='\n'.join(host_dict.keys())+'\n', text=True, capture_output=True)
699
+ lines = res.stdout.splitlines()
700
+ desc_titles = lines[0].strip().split('\t')[1:] # Get the description titles from the first line
701
+ lines.pop(0) # Remove the first line with titles
702
+ for line in lines:
703
+ info = line.strip().split('\t')
704
+ fqdn = info[0].lower()
705
+ info.pop(0) # Remove the hostname from the info list
706
+ if fqdn in host_dict:
707
+ host_dict[fqdn] = (host_dict[fqdn][0], info)
708
+ hosts = []
709
+ for fqdn in host_dict:
710
+ ports, desc = host_dict[fqdn]
711
+ hosts.append((fqdn, sorted(ports), desc))
712
+ return (hosts, desc_titles)
713
+
714
+ def ping_host(ip: str, timeout: int = 2) -> bool:
715
+ """Test if a host responds to ICMP ping.
716
+
717
+ Uses the system ping command to check host availability. The command is
718
+ configured for a single ping attempt with a specified timeout.
719
+
720
+ Args:
721
+ ip: IP address to ping
722
+ timeout: Maximum time to wait for response in seconds
723
+
724
+ Returns:
725
+ True if host responds to ping, False otherwise
726
+ """
727
+ timeoutms = str(int(timeout * 1000))
728
+ ping_cmd = PING.format(ip=ip, timeout=timeout, timeoutms=timeoutms).split()
729
+ try:
730
+ output = subprocess.run(ping_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout + 1)
731
+ return output.returncode == 0
732
+ except Exception:
733
+ return False
734
+
735
+ def is_ip(hostname: str) -> bool:
736
+ """Check if a given hostname is an IP address.
737
+
738
+ Uses inet_aton to determine if the input string is a valid
739
+ IPv4 address.
740
+
741
+ Args:
742
+ hostname: The hostname or IP address to check
743
+
744
+ Returns:
745
+ True if the hostname is an IP address, False otherwise
746
+ """
747
+ try:
748
+ socket.inet_aton(hostname)
749
+ return True
750
+ except socket.error:
751
+ return False
752
+
753
+ def resolve_and_ping_host(hostname: str, timeout: int = 2, noping: bool = False) -> Tuple[str, Dict[str, Union[str, bool]]]:
754
+ """Resolve a hostname to IP and optionally check if it responds to ping.
755
+
756
+ Performs DNS resolution and ping check in a single function. For IP addresses,
757
+ attempts reverse DNS lookup to get the hostname.
758
+
759
+ Args:
760
+ hostname: Hostname or IP address to resolve
761
+ timeout: Timeout for DNS and ping operations in seconds
762
+ noping: If True, skip the ping check
763
+
764
+ Returns:
765
+ A tuple containing:
766
+ - str: The hostname (which might be updated from reverse DNS)
767
+ - dict: Host information with keys:
768
+ - 'ip': IP address or 'N/A' if resolution fails
769
+ - 'ping': Boolean ping status
770
+ - 'hostname': Original resolved hostname (only if different from input)
771
+ """
772
+ try:
773
+ # If it's an IP, try to get hostname from reverse DNS
774
+ if is_ip(hostname):
775
+ try:
776
+ ip = hostname
777
+ resolved_hosts = socket.gethostbyaddr(ip)
778
+ if resolved_hosts and resolved_hosts[0]:
779
+ hostname = resolved_hosts[0] # Use the resolved hostname
780
+ except (socket.herror, socket.gaierror):
781
+ pass # Keep the IP as hostname if reverse lookup fails
782
+ else:
783
+ ip = socket.gethostbyname(hostname)
784
+ if noping:
785
+ return hostname, {'ip': ip, 'ping': False}
786
+ ping_status = ping_host(ip, timeout)
787
+ return hostname, {'ip': ip, 'ping': ping_status}
788
+ except Exception:
789
+ return hostname, {'ip': 'N/A', 'ping': False}
790
+
791
+ def ping_hosts(hosts: List[Tuple[str, List[int], str]],
792
+ timeout: int = 2,
793
+ parallelism: int = 10,
794
+ noping: bool = False) -> Dict[str, Dict[str, Union[str, bool]]]:
795
+ """Process DNS resolution and ping checks for multiple hosts in parallel.
796
+
797
+ Uses a thread pool to concurrently resolve hostnames to IPs and optionally
798
+ check their ping status. Includes a progress bar to show completion status.
799
+
800
+ Args:
801
+ hosts: List of (hostname, ports) tuples to process
802
+ timeout: Maximum time to wait for each operation in seconds
803
+ parallelism: Maximum number of concurrent threads to use
804
+ noping: If True, skip the ping checks
805
+
806
+ Returns:
807
+ Dictionary mapping hostnames to their information:
808
+ hostname -> {
809
+ 'ip': IP address or 'N/A' if resolution fails,
810
+ 'ping': Boolean ping status,
811
+ 'hostname': Original resolved hostname (if different from input)
812
+ }
813
+ """
814
+ results = {}
815
+
816
+ if noping:
817
+ print("Resolving DNS...", file=sys.stderr)
818
+ else:
819
+ print("Resolving DNS and pinging hosts...", file=sys.stderr)
820
+ progress_bar = ProgressBar(len(hosts), prefix='Host Discovery')
821
+
822
+ # Use a lock for thread-safe progress updates
823
+ lock = threading.Lock()
824
+
825
+ with ThreadPoolExecutor(max_workers=parallelism) as executor:
826
+ future_to_host = {
827
+ executor.submit(resolve_and_ping_host, hostname, timeout, noping): hostname
828
+ for hostname, _, _ in hosts
829
+ }
830
+
831
+ for future in as_completed(future_to_host):
832
+ orig_hostname = future_to_host[future]
833
+ resolved_hostname, info = future.result()
834
+ # Store with original hostname as key for matching with ports later
835
+ results[orig_hostname] = info
836
+ if resolved_hostname != orig_hostname:
837
+ # Keep resolved hostname in the info dict
838
+ results[orig_hostname]['hostname'] = resolved_hostname
839
+ with lock:
840
+ # Update progress bar in a thread-safe manner
841
+ progress_bar.update(1)
842
+
843
+ print(file=sys.stderr) # New line after progress bar
844
+ return results
845
+
846
+ def check_port(hostname: str,
847
+ port: int,
848
+ host_info: Dict[str, Union[str, bool]],
849
+ desc: list,
850
+ timeout: int = 2) -> Tuple[str, str, int, str, bool]:
851
+ """Check if a specific TCP port is accessible on a host.
852
+
853
+ Attempts to establish a TCP connection to the specified port. Uses pre-resolved
854
+ IP address information to avoid redundant DNS lookups.
855
+
856
+ Args:
857
+ hostname: The hostname to check
858
+ port: The TCP port number to check
859
+ host_info: Dictionary containing pre-resolved host information:
860
+ - 'ip': IP address or 'N/A' if resolution failed
861
+ - 'ping': Boolean indicating ping status
862
+ - 'hostname': Optional resolved hostname from reverse DNS
863
+ desc: Optional description for the host
864
+ timeout: Maximum time to wait for connection in seconds
865
+
866
+ Returns:
867
+ Tuple containing:
868
+ - display_hostname: Either the original or resolved hostname
869
+ - ip: The IP address or 'N/A' if resolution failed
870
+ - port: The port number that was checked
871
+ - status: Connection status (CONNECTED/TIMEOUT/REFUSED/UNREACHABLE)
872
+ - ping: Boolean indicating if the host responded to ping
873
+ - desc: Optional description for the host
874
+
875
+ Status meanings:
876
+ CONNECTED: Successfully established TCP connection
877
+ TIMEOUT: Connection attempt timed out
878
+ REFUSED: Host actively refused the connection
879
+ UNREACHABLE: Network error or host unreachable
880
+ RESOLVE_FAIL: Could not resolve hostname to IP
881
+ """
882
+ if host_info['ip'] == 'N/A':
883
+ return (hostname, host_info['ip'], port, 'RESOLVE_FAIL', host_info['ping'], desc)
884
+
885
+ # Use resolved hostname if available
886
+ display_hostname = host_info.get('hostname', hostname)
887
+
888
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
889
+ s.settimeout(timeout)
890
+ try:
891
+ s.connect((host_info['ip'], port))
892
+ s.close()
893
+ return (display_hostname, host_info['ip'], port, 'CONNECTED', host_info['ping'], desc)
894
+ except ConnectionAbortedError:
895
+ return (display_hostname, host_info['ip'], port, 'CONNECTED', host_info['ping'], desc)
896
+ except (TimeoutError, socket.timeout):
897
+ return (display_hostname, host_info['ip'], port, 'TIMEOUT', host_info['ping'], desc)
898
+ except ConnectionRefusedError:
899
+ return (display_hostname, host_info['ip'], port, 'REFUSED', host_info['ping'], desc)
900
+ except Exception:
901
+ # Handle other network errors (filtered, network unreachable, etc)
902
+ return (display_hostname, host_info['ip'], port, 'UNREACHABLE', host_info['ping'], desc)
903
+
904
+ def send_email_report(
905
+ output_file: str,
906
+ mailhost: str,
907
+ sender: str,
908
+ recipients: List[str],
909
+ subject: str,
910
+ stats: Dict[str, Any],
911
+ parallelism: int,
912
+ timeout: float
913
+ ) -> bool:
914
+ """Send the HTML report as an email attachment with a summary in the body.
915
+
916
+ Creates a multipart email with:
917
+ 1. An HTML body containing a summary of the scan results
918
+ 2. The full HTML report as an attachment
919
+
920
+ Args:
921
+ output_file: Path to the generated HTML report file
922
+ mailhost: SMTP server hostname
923
+ sender: Email address of the sender
924
+ recipients: List of recipient email addresses
925
+ subject: Email subject line
926
+ stats: Dictionary containing scan statistics and results
927
+ parallelism: Number of parallel threads used in scan
928
+ timeout: Timeout value used in scan
929
+
930
+ Returns:
931
+ bool: True if email was sent successfully, False otherwise
932
+ """
933
+ msg = MIMEMultipart()
934
+ msg['From'] = sender
935
+ msg['To'] = ', '.join(recipients)
936
+ msg['Subject'] = subject
937
+ style = '''
938
+ <style>
939
+ html { font-size: 10%; color: #fff; }
940
+ h3 { color: #444; margin: 10px 0 5px; }
941
+ table { border-collapse: collapse; margin-right: 20px; }
942
+ th, td { font-size: 85%; padding: 2px 4px; text-align: left; border-bottom: 1px solid #ddd; }
943
+ th { background-color: #444; color: #eee; }
944
+ .green { color: green; }
945
+ .red { color: red; }
946
+ </style>
947
+ '''
948
+ # Create HTML body with scan summary
949
+ html_body = f'''
950
+ <html>
951
+ <head>
952
+ <meta charset="utf-8">
953
+ {style}
954
+ </head>
955
+ <body>
956
+ <h3>Port Scan Summary from {HOSTNAME} ({MY_IP})</h3>'''
957
+
958
+ html_body += generate_html_summary(stats, parallelism, timeout)
959
+ html_body += '''
960
+ <p>Please find the full detailed report attached.</p>
961
+ </body>
962
+ </html>
963
+ '''
964
+
965
+ # Add HTML body and attachment
966
+ msg.attach(MIMEText(html_body, 'html'))
967
+ with open(output_file, 'rb') as f:
968
+ attachment = MIMEApplication(f.read(), _subtype='html')
969
+ attachment.add_header('Content-Disposition', 'attachment', filename=output_file)
970
+ msg.attach(attachment)
971
+
972
+ # Send email using SMTP
973
+ try:
974
+ with smtplib.SMTP(mailhost) as server:
975
+ server.send_message(msg)
976
+ return True
977
+ except Exception as e:
978
+ print(f"Failed to send email: {str(e)}", file=sys.stderr)
979
+ return False
980
+
981
+ def format_percent(value: int, total: int) -> Tuple[str, str]:
982
+ """Format a value as a percentage with separate value/total and percent strings.
983
+
984
+ Args:
985
+ value: The count to calculate percentage for
986
+ total: The total count to calculate percentage against
987
+
988
+ Returns:
989
+ A tuple containing:
990
+ - str: Formatted as "value/total"
991
+ - str: Formatted as "xx.x%" with one decimal place
992
+ """
993
+ if total == 0:
994
+ return "0/0", "0.0%"
995
+ return f"{value}/{total}", f"{(value/total*100):.1f}%"
996
+
997
+ def get_vlan_base(ip: str, bits: int) -> str:
998
+ """Calculate VLAN base address with end padding 0."""
999
+ if ip == 'N/A':
1000
+ return 'N/A'
1001
+ try:
1002
+ octets = ip.split('.')
1003
+ if len(octets) != 4:
1004
+ return 'invalid'
1005
+
1006
+ # Convert IP to 32-bit integer
1007
+ ip_int = sum(int(octet) << (24 - 8 * i) for i, octet in enumerate(octets))
1008
+
1009
+ # Apply mask
1010
+ mask = ((1 << bits) - 1) << (32 - bits)
1011
+ masked_ip = ip_int & mask
1012
+
1013
+ # Convert back to dotted notation
1014
+ result_octets = [(masked_ip >> (24 - 8 * i)) & 255 for i in range(4)]
1015
+ return '.'.join(map(str, result_octets))
1016
+ except:
1017
+ return 'N/A'
1018
+
1019
+
1020
+ def compute_stats(
1021
+ results: List[Tuple[str, str, int, str, bool]],
1022
+ start_time: float,
1023
+ bits: int) -> Dict[str, Any]:
1024
+ """Compute comprehensive statistics from scan results.
1025
+
1026
+ Processes the raw scan results to generate statistics about:
1027
+ - Overall scan duration and counts
1028
+ - Host status (pingable, unreachable, etc.)
1029
+ - Port status (connected, refused, etc.)
1030
+ - Domain-specific statistics
1031
+ - VLAN timeout patterns
1032
+
1033
+ Args:
1034
+ results: List of scan results, each containing:
1035
+ - hostname
1036
+ - ip
1037
+ - port
1038
+ - status (CONNECTED/TIMEOUT/REFUSED/etc.)
1039
+ - ping status
1040
+ start_time: Timestamp when the scan started
1041
+
1042
+ Returns:
1043
+ Dictionary containing various statistics categories:
1044
+ - duration: Total scan time
1045
+ - total_ports: Number of ports scanned
1046
+ - ports: Counts by port status
1047
+ - total_hosts: Number of unique hosts
1048
+ - hosts: Counts by host status
1049
+ - domains: Statistics by domain
1050
+ - vlan_timeouts: Timeout patterns by VLAN
1051
+ - Various formatted summaries for display
1052
+ """
1053
+ stats = {
1054
+ 'duration': time.time() - start_time,
1055
+ 'total_ports': len(results),
1056
+ 'ports': {
1057
+ 'connected': sum(1 for r in results if r[3] == 'CONNECTED'),
1058
+ 'refused': sum(1 for r in results if r[3] == 'REFUSED'),
1059
+ 'timeout': sum(1 for r in results if r[3] == 'TIMEOUT'),
1060
+ 'unreachable': sum(1 for r in results if r[3] == 'UNREACHABLE'),
1061
+ 'resolve_fail': sum(1 for r in results if r[3] == 'RESOLVE_FAIL')
1062
+ },
1063
+ 'vlan_timeouts': defaultdict(lambda: {'timeouts': 0, 'total': 0}),
1064
+ 'vlan_bits': bits,
1065
+ }
1066
+
1067
+
1068
+
1069
+ # Collect VLAN statistics for timeouts
1070
+ for hostname, ip, port, status, _, _ in results:
1071
+ if ip != 'N/A':
1072
+ try:
1073
+ vlan = get_vlan_base(ip, bits)
1074
+ stats['vlan_timeouts'][vlan]['total'] += 1
1075
+ if status == 'TIMEOUT':
1076
+ stats['vlan_timeouts'][vlan]['timeouts'] += 1
1077
+ except:
1078
+ pass
1079
+
1080
+
1081
+ # Group results by hostname for host statistics
1082
+ host_stats = defaultdict(lambda: {'statuses': [], 'ping': False})
1083
+ for result in results:
1084
+ hostname, _, _, status, ping, _ = result
1085
+ host_stats[hostname]['statuses'].append(status)
1086
+ host_stats[hostname]['ping'] |= ping
1087
+
1088
+ stats.update({
1089
+ 'total_hosts': len(host_stats),
1090
+ 'hosts': {
1091
+ 'pingable': sum(1 for host in host_stats.values() if host['ping']),
1092
+ 'all_open': sum(1 for host in host_stats.values() if all(s in ['CONNECTED', 'REFUSED'] for s in host['statuses'])),
1093
+ 'with_timeout': sum(1 for host in host_stats.values() if any(s == 'TIMEOUT' for s in host['statuses'])),
1094
+ 'unresolved': sum(1 for host in host_stats.values() if all(s == 'RESOLVE_FAIL' for s in host['statuses']))
1095
+ }
1096
+ })
1097
+
1098
+ # Compute domain statistics
1099
+ domain_stats = defaultdict(lambda: {
1100
+ 'hosts': set(),
1101
+ 'ports': defaultdict(lambda: {
1102
+ 'connected': 0,
1103
+ 'refused': 0,
1104
+ 'timeout': 0,
1105
+ 'unreachable': 0,
1106
+ 'resolve_fail': 0,
1107
+ 'total': 0
1108
+ })
1109
+ })
1110
+
1111
+ for hostname, ip, port, status, _, _ in results:
1112
+ if is_ip(hostname):
1113
+ # For IP addresses, use VLAN/16 as domain
1114
+ domain = '.'.join(hostname.split('.')[:2]) + '.0.0'
1115
+ else:
1116
+ # Not an IP address, extract domain normally
1117
+ parts = hostname.split('.')
1118
+ if len(parts) > 1:
1119
+ domain = '.'.join(parts[1:]) # Remove first part to get domain
1120
+ else:
1121
+ domain = 'local' # For hostnames without domain
1122
+
1123
+ domain_stats[domain]['hosts'].add(hostname)
1124
+ domain_stats[domain]['ports'][port]['total'] += 1
1125
+ domain_stats[domain]['ports'][port][status.lower()] += 1
1126
+
1127
+ # Create formatted domain statistics with port grouping
1128
+ stats['domains'] = {}
1129
+ for domain, dstats in domain_stats.items():
1130
+ # Separate ports into two groups: <=1024 and >1024
1131
+ low_ports = {p: stats for p, stats in dstats['ports'].items() if p <= 1024}
1132
+ high_ports = {p: stats for p, stats in dstats['ports'].items() if p > 1024}
1133
+ # Calculate combined stats for high ports
1134
+ if high_ports:
1135
+ high_ports_combined = {
1136
+ 'connected': sum(s['connected'] for s in high_ports.values()),
1137
+ 'refused': sum(s['refused'] for s in high_ports.values()),
1138
+ 'timeout': sum(s['timeout'] for s in high_ports.values()),
1139
+ 'unreachable': sum(s['unreachable'] for s in high_ports.values()),
1140
+ 'resolve_fail': sum(s['resolve_fail'] for s in high_ports.values()),
1141
+ 'total': sum(s['total'] for s in high_ports.values())
1142
+ }
1143
+ else:
1144
+ high_ports_combined = None
1145
+
1146
+ stats['domains'][domain] = {
1147
+ 'total_hosts': len(dstats['hosts']),
1148
+ 'low_ports': low_ports,
1149
+ 'high_ports': high_ports_combined
1150
+ }
1151
+
1152
+ # Create formatted summary data for display
1153
+ def format_percent(value, total):
1154
+ if total == 0:
1155
+ return "0/0 (0.0%)"
1156
+ return f"{value}/{total} ({(value/total*100):.1f}%)"
1157
+
1158
+ # Host status items
1159
+ stats['hosts_summary'] = [
1160
+ ("Responding to ping", lambda s: format_percent(s['hosts']['pingable'], s['total_hosts'])),
1161
+ ("All ports open", lambda s: format_percent(s['hosts']['all_open'], s['total_hosts'])),
1162
+ ("Not responding to ping", lambda s: format_percent(s['total_hosts'] - s['hosts']['pingable'], s['total_hosts'])),
1163
+ ("Failed to resolve", lambda s: format_percent(s['hosts']['unresolved'], s['total_hosts'])),
1164
+ ("With timeout ports", lambda s: format_percent(s['hosts']['with_timeout'], s['total_hosts']))
1165
+ ]
1166
+
1167
+ # Port status items
1168
+ stats['ports_summary'] = [
1169
+ ("Connected", lambda s: {'value': s['ports']['connected'], 'total': s['total_ports']}),
1170
+ ("Refused", lambda s: {'value': s['ports']['refused'], 'total': s['total_ports']}),
1171
+ ("Timeout", lambda s: {'value': s['ports']['timeout'], 'total': s['total_ports']}),
1172
+ ("Unreachable", lambda s: {'value': s['ports']['unreachable'], 'total': s['total_ports']}),
1173
+ ("Failed to resolve", lambda s: {'value': s['ports']['resolve_fail'], 'total': s['total_ports']})
1174
+ ]
1175
+
1176
+ # Domain status items
1177
+ stats['domains_summary'] = []
1178
+ for domain, dstats in sorted(stats['domains'].items()):
1179
+ total_hosts = dstats['total_hosts']
1180
+ stats['domains_summary'].append((
1181
+ domain,
1182
+ lambda s, d=domain: {
1183
+ 'total_hosts': s['domains'][d]['total_hosts'],
1184
+ 'ports': (
1185
+ # Add low ports (<=1024)
1186
+ [(
1187
+ port,
1188
+ {
1189
+ 'connected': pstats['connected'],
1190
+ 'refused': pstats['refused'],
1191
+ 'timeout': pstats['timeout'],
1192
+ 'total': pstats['total']
1193
+ }
1194
+ ) for port, pstats in sorted(s['domains'][d]['low_ports'].items())]
1195
+ +
1196
+ # Add high ports (>1024) as a single combined entry if they exist
1197
+ ([
1198
+ ('>1024',
1199
+ {
1200
+ 'connected': s['domains'][d]['high_ports']['connected'],
1201
+ 'refused': s['domains'][d]['high_ports']['refused'],
1202
+ 'timeout': s['domains'][d]['high_ports']['timeout'],
1203
+ 'total': s['domains'][d]['high_ports']['total']
1204
+ }
1205
+ )] if s['domains'][d]['high_ports'] else [])
1206
+ )
1207
+ }
1208
+ ))
1209
+
1210
+ # Add Scan Summary items
1211
+ stats['scan_summary'] = [
1212
+ ("Date (duration)", lambda s: f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(start_time))} ({s['duration']:.2f}s)"),
1213
+ ("Total hosts scanned", lambda s: str(s['total_hosts'])),
1214
+ ("Total ports scanned", lambda s: str(s['total_ports']))
1215
+ ]
1216
+
1217
+ return stats
1218
+
1219
+ def generate_html_report(
1220
+ results: List[Tuple[str, str, int, str, bool]],
1221
+ output_file: str,
1222
+ scan_time: time.struct_time,
1223
+ timeout: float,
1224
+ parallelism: int,
1225
+ noping: bool,
1226
+ stats: Dict[str, Any],
1227
+ input_file: str = '',
1228
+ desc_titles: List[str] = [],
1229
+ filter: str = '',
1230
+ ) -> None:
1231
+ """Generate a complete HTML report of the port scan results.
1232
+
1233
+ Creates a detailed HTML report including:
1234
+ - A table of all scan results
1235
+ - Summary statistics
1236
+ - Domain and VLAN analysis
1237
+ - Interactive features for sorting and filtering
1238
+
1239
+ The report uses custom CSS for styling and JavaScript for interactivity.
1240
+
1241
+ Args:
1242
+ results: List of scan results, each containing:
1243
+ - hostname (str)
1244
+ - ip (str)
1245
+ - port (int)
1246
+ - status (str)
1247
+ - ping status (bool)
1248
+ output_file: Path where the HTML report should be saved
1249
+ scan_time: Time when the scan was started
1250
+ timeout: Timeout value used for port checks
1251
+ parallelism: Number of parallel threads used
1252
+ noping: Whether ping checks were disabled
1253
+ stats: Dictionary containing all computed statistics
1254
+ input_file: Path to the input file used for the scan (for report header)
1255
+ """
1256
+
1257
+ with open(output_file, 'w', encoding='utf-8') as f:
1258
+ # Write HTML header with CSS and metadata
1259
+ f.write(f'''<!DOCTYPE html>
1260
+ <html>
1261
+ <head>
1262
+ <title>re-port: {HOSTNAME}</title>
1263
+ <meta charset="utf-8">
1264
+ <link rel="icon" href="{ICON}" type="image/svg+xml">
1265
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1266
+ {CSS}
1267
+ </head>
1268
+ <body>''')
1269
+
1270
+ # Write report header
1271
+ f.write(f'<h3 class="icon">Port Accessibility Report from {HOSTNAME} ({MY_IP}) to {os.path.basename(input_file)} - {time.strftime("%Y-%m-%d %H:%M:%S", scan_time)}</h3>\n')
1272
+
1273
+ # Write detailed results table
1274
+ if not desc_titles:
1275
+ _, _, _, _, _, desc = results[0]
1276
+ desc_titles = ["Description" for d in desc]
1277
+ f.write(f'''
1278
+ <div class="table-container" id="result-container">
1279
+ <table id="commandTable">
1280
+ <thead>
1281
+ <tr>
1282
+ <th>Hostname</th>
1283
+ <th>IP</th>
1284
+ <th>VLAN/{stats['vlan_bits']}</th>
1285
+ <th>Port</th>
1286
+ <th>Status</th>
1287
+ <th>Ping</th>
1288
+ {"""
1289
+ """.join([f"<th>{d}</th>" for d in desc_titles])}
1290
+ </tr>
1291
+ </thead>
1292
+ <tbody>
1293
+ ''')
1294
+
1295
+ # Add result rows
1296
+ for hostname, ip, port, status, ping, desc in results:
1297
+ ping_status = 'UP' if ping else 'N/A' if noping else 'DOWN'
1298
+ ping_class = 'green' if ping else 'blue' if noping else 'red'
1299
+ status_class = 'green' if status == 'CONNECTED' else 'blue' if status == 'REFUSED' else 'red'
1300
+ row_class = ''
1301
+ if filter and status != filter:
1302
+ row_class = 'hidden'
1303
+ f.write(f'''
1304
+ <tr class="{row_class}">
1305
+ <td>{escape(str(hostname))}</td>
1306
+ <td>{escape(str(ip))}</td>
1307
+ <td>{str(get_vlan_base(ip, stats['vlan_bits']))}</td>
1308
+ <td style="text-align: right;">{port}</td>
1309
+ <td style="text-align: center;"><span class="{status_class} status">{escape(status)}</span></td>
1310
+ <td style="text-align: center;"><span class="{ping_class} ping">{ping_status}</span></td>
1311
+ {"""
1312
+ """.join([f'<td class="desc">{escape(desc[i] if i < len(desc) else "")}</td>' for i in range(len(desc_titles))])}
1313
+ </tr>
1314
+ ''')
1315
+
1316
+ f.write('</tbody></table></div>\n')
1317
+
1318
+ # Add summary and statistics
1319
+ f.write(generate_html_summary(stats, parallelism, timeout))
1320
+
1321
+ # Add JavaScript for interactivity
1322
+ f.write(f'</body>{JS.replace("{FILTER}", filter)}</html>\n')
1323
+
1324
+ def print_statistics(stats, timeout, parallelism):
1325
+ def format_percent(value, total):
1326
+ if total == 0:
1327
+ return "0/0", "0.0%"
1328
+ return f"{value}/{total}", f"{(value/total*100):.1f}%"
1329
+
1330
+ # Print Scan Summary
1331
+ print("\nScan Summary:", file=sys.stderr)
1332
+ for label, value_func in stats['scan_summary']:
1333
+ print(f" {label}: {value_func(stats)}", file=sys.stderr)
1334
+ print(f" Parallel threads: {parallelism}", file=sys.stderr)
1335
+ print(f" Timeout: {timeout:.1f} seconds", file=sys.stderr)
1336
+
1337
+ # Print Hosts Status
1338
+ print("\nHosts Status:", file=sys.stderr)
1339
+ for label, value_func in stats['hosts_summary']:
1340
+ print(f" {label}: {value_func(stats)}", file=sys.stderr)
1341
+
1342
+ # Print Ports Status
1343
+ print("\nPorts Status:", file=sys.stderr)
1344
+ for label, value_func in stats['ports_summary']:
1345
+ data = value_func(stats)
1346
+ value_str, pct_str = format_percent(data['value'], data['total'])
1347
+ print(f" {label}: {value_str} ({pct_str})", file=sys.stderr)
1348
+
1349
+ # Print DNS Domain Statistics
1350
+ print("\nDNS Domain Statistics:", file=sys.stderr)
1351
+
1352
+ # Define columns and their headers
1353
+ headers = ['Domain', 'Total Hosts', 'Port', 'Connected/Refused', '%', 'Timeout', '%']
1354
+
1355
+ # Get maximum width for each column based on content
1356
+ widths = {
1357
+ 'Domain': max(len('Domain'), max((len(str(d)) for d, _ in stats['domains_summary']))),
1358
+ 'Total Hosts': len('Total Hosts'),
1359
+ 'Port': len('Port'),
1360
+ 'Connected/Refused': len('Connected/Refused'),
1361
+ '%': len('100.0%'),
1362
+ 'Timeout': len('Timeout'),
1363
+ '%_2': len('100.0%')
1364
+ }
1365
+
1366
+ # Update widths based on actual data
1367
+ for domain, value_func in stats['domains_summary']:
1368
+ domain_data = value_func(stats)
1369
+ widths['Total Hosts'] = max(widths['Total Hosts'], len(str(domain_data['total_hosts'])))
1370
+ for port, port_stats in domain_data['ports']:
1371
+ widths['Port'] = max(widths['Port'], len(str(port)))
1372
+ connected_refused = port_stats['connected'] + port_stats['refused']
1373
+ cr_value, cr_pct = format_percent(connected_refused, port_stats['total'])
1374
+ timeout_value, timeout_pct = format_percent(port_stats['timeout'], port_stats['total'])
1375
+ widths['Connected/Refused'] = max(widths['Connected/Refused'], len(cr_value))
1376
+ widths['Timeout'] = max(widths['Timeout'], len(timeout_value))
1377
+
1378
+ # Create the format string for each row
1379
+ row_format = '| {:<{}} | {:>{}} | {:>{}} | {:>{}} | {:>{}}'
1380
+ row_format += ' | {:>{}} | {:>{}} |'
1381
+
1382
+ # Create separator line
1383
+ separator = '+' + '+'.join('-' * (w + 2) for w in widths.values()) + '+'
1384
+
1385
+ # Print header
1386
+ print(separator, file=sys.stderr)
1387
+ print(row_format.format(
1388
+ 'Domain', widths['Domain'],
1389
+ 'Total Hosts', widths['Total Hosts'],
1390
+ 'Port', widths['Port'],
1391
+ 'Connected/Refused', widths['Connected/Refused'],
1392
+ '%', widths['%'],
1393
+ 'Timeout', widths['Timeout'],
1394
+ '%', widths['%_2']
1395
+ ), file=sys.stderr)
1396
+ print(separator, file=sys.stderr)
1397
+
1398
+ # Print data rows
1399
+ for domain, value_func in stats['domains_summary']:
1400
+ domain_data = value_func(stats)
1401
+ first_row = True
1402
+ for port, port_stats in domain_data['ports']:
1403
+ connected_refused = port_stats['connected'] + port_stats['refused']
1404
+ cr_value, cr_pct = format_percent(connected_refused, port_stats['total'])
1405
+ timeout_value, timeout_pct = format_percent(port_stats['timeout'], port_stats['total'])
1406
+
1407
+ print(row_format.format(
1408
+ domain if first_row else '', widths['Domain'],
1409
+ str(domain_data['total_hosts']) if first_row else '', widths['Total Hosts'],
1410
+ str(port), widths['Port'],
1411
+ cr_value, widths['Connected/Refused'],
1412
+ cr_pct, widths['%'],
1413
+ timeout_value, widths['Timeout'],
1414
+ timeout_pct, widths['%_2']
1415
+ ), file=sys.stderr)
1416
+ first_row = False
1417
+ if not first_row: # Only print separator between domains if domain had data
1418
+ print(separator, file=sys.stderr)
1419
+
1420
+ def format_table_output(
1421
+ results: List[Tuple[str, str, int, str, bool]],
1422
+ noping: bool
1423
+ ) -> str:
1424
+ """Format scan results as a text table for terminal output.
1425
+
1426
+ Creates a formatted ASCII table with columns aligned and proper borders.
1427
+ Adapts the output format based on whether stdout is a terminal or not.
1428
+
1429
+ Args:
1430
+ results: List of scan results, each containing:
1431
+ - hostname (str)
1432
+ - ip (str)
1433
+ - port (int)
1434
+ - status (str)
1435
+ - ping status (bool)
1436
+ noping: Whether ping checks were disabled
1437
+
1438
+ Returns:
1439
+ str: Formatted table string ready for terminal output
1440
+ """
1441
+ # Define columns and their headers
1442
+ headers = ['Hostname', 'IP', 'Port', 'Status', 'Ping']
1443
+
1444
+ # Get maximum width for each column based on content
1445
+ widths = {
1446
+ 'Hostname': max(len('Hostname'), max((len(str(r[0])) for r in results))),
1447
+ 'IP': max(len('IP'), max((len(str(r[1])) for r in results))),
1448
+ 'Port': max(len('Port'), max((len(str(r[2])) for r in results))),
1449
+ 'Status': max(len('Status'), max((len(str(r[3])) for r in results))),
1450
+ 'Ping': max(len('Ping'), max((len('UP' if r[4] else 'DOWN') for r in results)))
1451
+ }
1452
+
1453
+ # Create the format string for each row based on terminal type
1454
+ if sys.stdout.isatty():
1455
+ row_format = '| {:<{}} | {:<{}} | {:>{}} | {:<{}} | {:<{}} |'
1456
+ separator = '+' + '+'.join('-' * (w + 2) for w in widths.values()) + '+'
1457
+ else:
1458
+ row_format = '{:<{}} {:<{}} {:>{}} {:<{}} {:<{}}'
1459
+ separator = '-' * (sum(widths.values()) + len(headers) * 3 + 1)
1460
+
1461
+ # Create the table string
1462
+ table = []
1463
+ table.append(separator)
1464
+
1465
+ # Add header
1466
+ table.append(row_format.format(
1467
+ 'Hostname', widths['Hostname'],
1468
+ 'IP', widths['IP'],
1469
+ 'Port', widths['Port'],
1470
+ 'Status', widths['Status'],
1471
+ 'Ping', widths['Ping']
1472
+ ))
1473
+ table.append(separator)
1474
+
1475
+ # Add data rows
1476
+ for hostname, ip, port, status, ping, desc in results:
1477
+ ping_status = 'UP' if ping else 'N/A' if noping else 'DOWN'
1478
+ table.append(row_format.format(
1479
+ str(hostname), widths['Hostname'],
1480
+ str(ip), widths['IP'],
1481
+ str(port), widths['Port'],
1482
+ str(status), widths['Status'],
1483
+ ping_status, widths['Ping']
1484
+ ))
1485
+
1486
+ table.append(separator)
1487
+ return '\n'.join(table)
1488
+
1489
+ def main():
1490
+ parser = argparse.ArgumentParser(description='Port accessibility report utility')
1491
+ parser.add_argument('-p', '--parallelism', type=int, default=50, help='Number of parallel threads (default: 50)')
1492
+ parser.add_argument('-t', '--timeout', type=float, default=2, help='Timeout in seconds for port and ping checks (default: 2)')
1493
+ parser.add_argument('-o', '--output', default=f'report_{HOSTNAME}.{time.strftime("%Y%m%d_%H%M%S")}.html', help='Output HTML report file')
1494
+ parser.add_argument('-n', '--noping', action="store_true", help='No ping check')
1495
+ parser.add_argument('-s', '--summary', action="store_true", help='Print scan summary information')
1496
+ parser.add_argument('-b', '--bits', type=int, default=16, help='VLAN bits for timeout summary (default: 16)')
1497
+ parser.add_argument('-d', '--desc_titles', type=str, nargs='*', help='List of custom description titles for hosts (optional)')
1498
+ parser.add_argument('-f', '--filter', type=str, help='default status filter for html report (optional)',
1499
+ choices=['CONNECTED', 'REFUSED', 'TIMEOUT', 'UNREACHABLE', 'RESOLVE_FAIL'], default='')
1500
+ parser.add_argument('-i', '--info_command', type=str, help='Exernal command to get hosts information (optional)',)
1501
+
1502
+ # Email related arguments
1503
+ email_group = parser.add_argument_group('Email Options')
1504
+ email_group.add_argument('--email-to', help='Comma-separated list of email recipients')
1505
+ email_group.add_argument('--email-from', help='Sender email address')
1506
+ email_group.add_argument('--email-subject', help='Email subject')
1507
+ email_group.add_argument('--mailhost', default='mailhost', help='Mail server host (default: mailhost)')
1508
+
1509
+ parser.add_argument('input_file', help='Input file with hostnames and ports (fqdn port1,port2,...)')
1510
+
1511
+ args = parser.parse_args()
1512
+
1513
+ start_time = time.time()
1514
+ scan_datetime = time.localtime()
1515
+ if not os.path.exists(args.input_file):
1516
+ print(f"Input file '{args.input_file}' does not exist.", file=sys.stderr)
1517
+ sys.exit(1)
1518
+ (hosts, desc_titles) = parse_input_file(args.input_file, args.info_command)
1519
+ if not hosts:
1520
+ print(f"No valid hosts found in input file '{args.input_file}'.", file=sys.stderr)
1521
+ sys.exit(1)
1522
+ # First, do DNS resolution and ping in one pass
1523
+ host_info = ping_hosts(hosts, args.timeout, args.parallelism, args.noping)
1524
+
1525
+ # Calculate total tasks and initialize progress bar
1526
+ total_tasks = sum(len(ports) for _, ports, _ in hosts)
1527
+ print(f"Preparing to scan {len(hosts)} hosts with {total_tasks} total ports...", file=sys.stderr)
1528
+
1529
+ # Prepare tasks with pre-resolved data
1530
+ tasks = []
1531
+ for hostname, ports, desc in hosts:
1532
+ for port in ports:
1533
+ tasks.append((hostname, port, host_info[hostname], desc))
1534
+
1535
+ results = []
1536
+ lock = threading.Lock()
1537
+
1538
+ # Create progress bar
1539
+ progress_bar = ProgressBar(total_tasks, prefix='Scanning')
1540
+
1541
+ with ThreadPoolExecutor(max_workers=args.parallelism) as executor:
1542
+ future_to_task = {executor.submit(check_port, hostname, port, info, desc, args.timeout): (hostname, port, info)
1543
+ for hostname, port, info, desc in tasks}
1544
+
1545
+ for future in as_completed(future_to_task):
1546
+ res = future.result()
1547
+ with lock:
1548
+ results.append(res)
1549
+ progress_bar.update(1)
1550
+
1551
+ # Add a newline after progress bar completion
1552
+ print(file=sys.stderr)
1553
+
1554
+ # Calculate all statistics in one place
1555
+ stats = compute_stats(results, start_time, args.bits)
1556
+
1557
+ # Generate report
1558
+ results.sort(key=lambda x: (x[0], x[2]))
1559
+ generate_html_report(
1560
+ results,
1561
+ args.output,
1562
+ time.localtime(start_time),
1563
+ args.timeout,
1564
+ args.parallelism,
1565
+ args.noping,
1566
+ stats,
1567
+ args.input_file,
1568
+ args.desc_titles or desc_titles or [],
1569
+ args.filter,
1570
+ )
1571
+
1572
+ # Print summary
1573
+ # Display detailed results in table format
1574
+ print(format_table_output(results, args.noping))
1575
+ if args.summary:
1576
+ print_statistics(stats, args.timeout, args.parallelism)
1577
+
1578
+ print(f"\nReport generated: {args.output}", file=sys.stderr)
1579
+
1580
+ # Send email if recipient is provided
1581
+ if args.email_to:
1582
+ recipients = [r.strip() for r in args.email_to.split(',')]
1583
+ if send_email_report(
1584
+ args.output,
1585
+ args.mailhost,
1586
+ args.email_from or f'port-scanner@{HOSTNAME}',
1587
+ recipients,
1588
+ args.email_subject or f'Port Scan Report {HOSTNAME} ({MY_IP}) to {os.path.basename(args.input_file)}',
1589
+ stats,
1590
+ args.parallelism,
1591
+ args.timeout
1592
+ ):
1593
+ print(f"Report sent via email to: {', '.join(recipients)}", file=sys.stderr)
1594
+ else:
1595
+ print("Failed to send email report", file=sys.stderr)
1596
+ def format_percent(value, total):
1597
+ """Format a value as a percentage with the format: value/total with percent in separate output"""
1598
+ if total == 0:
1599
+ return "0/0", "0.0%"
1600
+ return f"{value}/{total}", f"{(value/total*100):.1f}%"
1601
+
1602
+ def generate_html_summary(
1603
+ stats: Dict[str, Any],
1604
+ parallelism: int,
1605
+ timeout: float
1606
+ ) -> str:
1607
+ """Generate HTML summary tables from scan statistics.
1608
+
1609
+ Creates HTML tables showing:
1610
+ 1. Scan summary (timing, counts)
1611
+ 2. Host status statistics
1612
+ 3. Port status statistics
1613
+ 4. Domain and VLAN statistics
1614
+
1615
+ Args:
1616
+ stats: Dictionary containing all scan statistics
1617
+ parallelism: Number of parallel threads used
1618
+ timeout: Timeout value used in seconds
1619
+
1620
+ Returns:
1621
+ str: HTML string containing formatted tables
1622
+ """
1623
+ html = []
1624
+
1625
+ # Summary table layout
1626
+ html.append('<table><tr style="vertical-align: top;">')
1627
+
1628
+ # Scan Summary section
1629
+ html.append('''
1630
+ <td style="border: 0;">
1631
+ <div class="table-container">
1632
+ <table><tr><th>Scan Summary</th><th>Value</th></tr>''')
1633
+
1634
+ for label, value_func in stats['scan_summary']:
1635
+ html.append(f'<tr><td>{label}</td><td>{value_func(stats)}</td></tr>')
1636
+ html.append(f'<tr><td>Parallel threads</td><td>{parallelism}</td></tr>')
1637
+ html.append(f'<tr><td>Timeout</td><td>{timeout:.1f}s</td></tr>')
1638
+ html.append('</table></div></td>')
1639
+
1640
+ # Hosts Status section
1641
+ html.append('''
1642
+ <td style="border: 0;">
1643
+ <div class="table-container">
1644
+ <table><tr><th>Hosts Status</th><th style="text-align: right;">Value</th><th style="text-align: right;">%</th></tr>''')
1645
+
1646
+ for label, value_func in stats['hosts_summary']:
1647
+ value_str = value_func(stats)
1648
+ if ' (' in value_str:
1649
+ value, pct = value_str.split(' (')
1650
+ pct = pct.rstrip(')')
1651
+ html.append(f'''<tr>
1652
+ <td>{label}</td>
1653
+ <td style="text-align: right;">{value}</td>
1654
+ <td style="text-align: right;">{pct}</td>
1655
+ </tr>''')
1656
+ html.append('</table></div></td>')
1657
+
1658
+ # Ports Status section
1659
+ html.append('''
1660
+ <td style="border: 0;">
1661
+ <div class="table-container">
1662
+ <table><tr><th>Ports Status</th><th style="text-align: right;">Value</th><th style="text-align: right;">%</th></tr>''')
1663
+
1664
+ for label, value_func in stats['ports_summary']:
1665
+ data = value_func(stats)
1666
+ value_str, pct_str = format_percent(data['value'], data['total'])
1667
+ html.append(f'''<tr>
1668
+ <td>{label}</td>
1669
+ <td style="text-align: right;">{value_str}</td>
1670
+ <td style="text-align: right;">{pct_str}</td>
1671
+ </tr>''')
1672
+ html.append('</table></div></td>')
1673
+ html.append('</tr></table>')
1674
+
1675
+ # DNS Domain Statistics
1676
+ html.append(f'''
1677
+ <table><tr style="vertical-align: top;">
1678
+ <td style="border: 0;"><h3>Summary for Domains/Ports</h3>
1679
+ <div class="table-container" style="display: inline-block;">
1680
+ <table><thead><tr>
1681
+ <th>Domain</th>
1682
+ <th style="text-align: right;">Total Hosts</th>
1683
+ <th style="text-align: right;">Port</th>
1684
+ <th style="text-align: right;">Connected/Refused</th>
1685
+ <th style="text-align: right;">%</th>
1686
+ <th style="text-align: right;">Timeout</th>
1687
+ <th style="text-align: right;">%</th>
1688
+ </tr></thead><tbody>''')
1689
+
1690
+ # Add domain statistics
1691
+ for domain, value_func in stats['domains_summary']:
1692
+ domain_data = value_func(stats)
1693
+ first_row = True
1694
+ for port, port_stats in domain_data['ports']:
1695
+ connected_refused = port_stats['connected'] + port_stats['refused']
1696
+ cr_value, cr_pct = format_percent(connected_refused, port_stats['total'])
1697
+ timeout_value, timeout_pct = format_percent(port_stats['timeout'], port_stats['total'])
1698
+ tm_class = 'green' if port_stats['timeout'] == 0 else 'red' if port_stats['timeout'] == port_stats['total'] else 'blue'
1699
+
1700
+ html.append(f'''<tr>
1701
+ <td>{'<strong>' + domain + '</strong>' if first_row else ''}</td>
1702
+ <td style="text-align: right;">{domain_data['total_hosts'] if first_row else ''}</td>
1703
+ <td style="text-align: right;">{port}</td>
1704
+ <td style="text-align: right;">{cr_value}</td>
1705
+ <td style="text-align: right;">{cr_pct}</td>
1706
+ <td style="text-align: right;">{timeout_value}</td>
1707
+ <td style="text-align: center;"><span class="{tm_class} pct">{timeout_pct}</span></td>
1708
+ </tr>''')
1709
+ first_row = False
1710
+
1711
+ html.append('</tbody></table></div>')
1712
+ html.append('</td><td style="border: 0;">')
1713
+
1714
+ # VLAN Timeout Statistics
1715
+ if stats['vlan_timeouts']:
1716
+ html.append(f'''
1717
+ <h3>VLAN/{stats['vlan_bits']} with Timeout</h3>
1718
+ <div class="table-container" style="display: inline-block;">
1719
+ <table><tr><th>VLAN/{stats['vlan_bits']}</th><th style="text-align: right;">Timeouts</th>
1720
+ <th style="text-align: right;">Total</th><th style="text-align: right;">%</th></tr>''')
1721
+
1722
+ # Sort VLANs by timeout percentage (descending)
1723
+ sorted_vlans = sorted(
1724
+ stats['vlan_timeouts'].items(),
1725
+ key=lambda x: (x[1]['timeouts'] / x[1]['total'] if x[1]['total'] > 0 else 0),
1726
+ reverse=True
1727
+ )
1728
+
1729
+ for vlan, data in sorted_vlans:
1730
+ if data['timeouts']:
1731
+ value_str, pct_str = format_percent(data['timeouts'], data['total'])
1732
+ tm_class = 'red' if data['timeouts'] == data['total'] else 'green' if data['timeouts'] == 0 else 'blue'
1733
+ html.append(f'''<tr>
1734
+ <td>{vlan}</td>
1735
+ <td style="text-align: right;">{data['timeouts']}</td>
1736
+ <td style="text-align: right;">{data['total']}</td>
1737
+ <td style="text-align: center;"><span class="{tm_class} pct">{pct_str}</span></td>
1738
+ </tr>''')
1739
+ html.append('</table></div>')
1740
+
1741
+ html.append('</td></tr></table>')
1742
+ return '\n'.join(html)
1743
+
1744
+ if __name__ == '__main__':
1745
+ main()