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/__init__.py +3 -0
- portune/portune.py +1745 -0
- portune/version.py +34 -0
- portune-1.0.2.dist-info/METADATA +55 -0
- portune-1.0.2.dist-info/RECORD +9 -0
- portune-1.0.2.dist-info/WHEEL +5 -0
- portune-1.0.2.dist-info/entry_points.txt +3 -0
- portune-1.0.2.dist-info/licenses/LICENSE +21 -0
- portune-1.0.2.dist-info/top_level.txt +1 -0
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 = '▲' //'⬆'; // 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}`; // 🡇🡇 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 = '▲'; //'⬆';
|
|
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 = '▼'; //'⬇';
|
|
336
|
+
} else if (currentOrder === 'desc') {
|
|
337
|
+
newOrder = '';
|
|
338
|
+
sortBtn.innerHTML = '▲'; //'⬆';
|
|
339
|
+
} else {
|
|
340
|
+
sortBtn.innerHTML = '▲'; //'⬆';
|
|
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()
|