portune 0.1.0__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.

Potentially problematic release.


This version of portune might be problematic. Click here for more details.

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