kport 2.1.1__py3-none-any.whl → 2.1.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.
- {kport-2.1.1.dist-info → kport-2.1.2.dist-info}/METADATA +509 -451
- kport-2.1.2.dist-info/RECORD +7 -0
- {kport-2.1.1.dist-info → kport-2.1.2.dist-info}/licenses/LICENSE +21 -21
- kport.py +1833 -567
- kport-2.1.1.dist-info/RECORD +0 -7
- {kport-2.1.1.dist-info → kport-2.1.2.dist-info}/WHEEL +0 -0
- {kport-2.1.1.dist-info → kport-2.1.2.dist-info}/entry_points.txt +0 -0
- {kport-2.1.1.dist-info → kport-2.1.2.dist-info}/top_level.txt +0 -0
kport.py
CHANGED
|
@@ -1,567 +1,1833 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
kport - Cross-platform port inspector and killer
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
kport - Cross-platform port inspector and killer (upgraded)
|
|
4
|
+
|
|
5
|
+
Features:
|
|
6
|
+
- Uses psutil when available for reliable cross-platform behavior.
|
|
7
|
+
- Safe subprocess usage (no shell=True).
|
|
8
|
+
- Class-based inspector architecture: UnixInspector / WindowsInspector.
|
|
9
|
+
- JSON output (--json).
|
|
10
|
+
- Dry-run (--dry-run).
|
|
11
|
+
- Graceful kill (SIGTERM) then forced kill (SIGKILL) fallback with timeout.
|
|
12
|
+
- --exact matching for process names, --yes to skip confirmations.
|
|
13
|
+
- Port range parsing with limit (max 1000 ports).
|
|
14
|
+
- Dependency checks and helpful error messages.
|
|
15
|
+
- Exit codes: 0 OK, 1 general error, 2 invalid input, 3 permission denied, 4 port used by Docker, 5 port free.
|
|
16
|
+
- Friendly tables with color, and machine-readable JSON.
|
|
17
|
+
|
|
18
|
+
Usage examples:
|
|
19
|
+
kport.py -i 8080
|
|
20
|
+
kport.py -im 3000 3001 3002
|
|
21
|
+
kport.py -ir 3000-3010
|
|
22
|
+
kport.py -ip node --exact
|
|
23
|
+
kport.py -k 8080 --yes
|
|
24
|
+
kport.py -kp node --dry-run --json
|
|
25
|
+
|
|
26
|
+
# PRODUCT.md subcommand interface
|
|
27
|
+
kport.py inspect 8080 --json
|
|
28
|
+
kport.py explain 8080
|
|
29
|
+
kport.py kill 8080
|
|
30
|
+
kport.py list
|
|
31
|
+
kport.py docker
|
|
32
|
+
kport.py conflicts
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import platform
|
|
41
|
+
import re
|
|
42
|
+
import shutil
|
|
43
|
+
import signal
|
|
44
|
+
import subprocess
|
|
45
|
+
import sys
|
|
46
|
+
import time
|
|
47
|
+
from dataclasses import dataclass, asdict
|
|
48
|
+
from typing import List, Dict, Optional, Tuple, Any
|
|
49
|
+
|
|
50
|
+
# Try optional psutil for robust cross-platform behavior
|
|
51
|
+
try:
|
|
52
|
+
import psutil # type: ignore
|
|
53
|
+
USING_PSUTIL = True
|
|
54
|
+
except Exception:
|
|
55
|
+
USING_PSUTIL = False
|
|
56
|
+
|
|
57
|
+
# Colors (simple)
|
|
58
|
+
class Colors:
|
|
59
|
+
RED = '\033[91m'
|
|
60
|
+
GREEN = '\033[92m'
|
|
61
|
+
YELLOW = '\033[93m'
|
|
62
|
+
BLUE = '\033[94m'
|
|
63
|
+
MAGENTA = '\033[95m'
|
|
64
|
+
CYAN = '\033[96m'
|
|
65
|
+
WHITE = '\033[97m'
|
|
66
|
+
BOLD = '\033[1m'
|
|
67
|
+
RESET = '\033[0m'
|
|
68
|
+
|
|
69
|
+
def colorize(text: str, color: str) -> str:
|
|
70
|
+
# basic check for Windows ANSI support: modern terminals typically handle this
|
|
71
|
+
if platform.system() == "Windows":
|
|
72
|
+
# Attempt simple enable; no-op if unsupported
|
|
73
|
+
try:
|
|
74
|
+
os.system("") # enable ANSI processing in some terminals
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
return f"{color}{text}{Colors.RESET}"
|
|
78
|
+
|
|
79
|
+
def check_dependency(cmd: str) -> bool:
|
|
80
|
+
"""Return True if `cmd` is found on PATH."""
|
|
81
|
+
return shutil.which(cmd) is not None
|
|
82
|
+
|
|
83
|
+
# Exit codes
|
|
84
|
+
EXIT_OK = 0
|
|
85
|
+
EXIT_GENERAL_ERROR = 1
|
|
86
|
+
EXIT_INVALID_INPUT = 2
|
|
87
|
+
EXIT_PERMISSION = 3
|
|
88
|
+
EXIT_PORT_DOCKER = 4
|
|
89
|
+
EXIT_PORT_FREE = 5
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def debug_log(enabled: bool, msg: str) -> None:
|
|
93
|
+
if enabled:
|
|
94
|
+
print(colorize(f"[debug] {msg}", Colors.BLUE), file=sys.stderr)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _default_config_paths() -> List[str]:
|
|
98
|
+
home = os.path.expanduser("~")
|
|
99
|
+
return [
|
|
100
|
+
os.path.join(os.getcwd(), ".kport.json"),
|
|
101
|
+
os.path.join(home, ".kport.json"),
|
|
102
|
+
os.path.join(home, ".config", "kport", "config.json"),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_config(config_path: Optional[str], debug: bool = False) -> Dict[str, Any]:
|
|
107
|
+
"""Load JSON config file if present.
|
|
108
|
+
|
|
109
|
+
Config is optional; parse failures are treated as invalid input.
|
|
110
|
+
"""
|
|
111
|
+
candidate_paths: List[str] = []
|
|
112
|
+
if config_path:
|
|
113
|
+
candidate_paths = [config_path]
|
|
114
|
+
else:
|
|
115
|
+
candidate_paths = _default_config_paths()
|
|
116
|
+
|
|
117
|
+
for path in candidate_paths:
|
|
118
|
+
if not path:
|
|
119
|
+
continue
|
|
120
|
+
path = os.path.expanduser(path)
|
|
121
|
+
if not os.path.exists(path):
|
|
122
|
+
continue
|
|
123
|
+
try:
|
|
124
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
125
|
+
data = json.load(f)
|
|
126
|
+
if isinstance(data, dict):
|
|
127
|
+
debug_log(debug, f"Loaded config: {path}")
|
|
128
|
+
return data
|
|
129
|
+
debug_log(debug, f"Ignoring non-object config: {path}")
|
|
130
|
+
except json.JSONDecodeError as e:
|
|
131
|
+
print(colorize(f"Error: invalid JSON in config file {path}: {e}", Colors.RED), file=sys.stderr)
|
|
132
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
print(colorize(f"Error: failed to read config file {path}: {e}", Colors.RED), file=sys.stderr)
|
|
135
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
136
|
+
return {}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def apply_config_defaults(args: argparse.Namespace, cfg: Dict[str, Any]) -> None:
|
|
140
|
+
"""Apply config as defaults (never overriding explicit CLI choices).
|
|
141
|
+
|
|
142
|
+
Supported keys:
|
|
143
|
+
- yes: bool
|
|
144
|
+
- dry_run: bool
|
|
145
|
+
- json: bool
|
|
146
|
+
- debug: bool
|
|
147
|
+
- force: bool
|
|
148
|
+
- graceful_timeout: number
|
|
149
|
+
- docker_action: "stop"|"restart"|"rm"
|
|
150
|
+
"""
|
|
151
|
+
def _set_bool(name: str, key: str) -> None:
|
|
152
|
+
if hasattr(args, name) and getattr(args, name) is False and isinstance(cfg.get(key), bool):
|
|
153
|
+
setattr(args, name, cfg[key])
|
|
154
|
+
|
|
155
|
+
def _set_num(name: str, key: str) -> None:
|
|
156
|
+
if hasattr(args, name) and cfg.get(key) is not None:
|
|
157
|
+
try:
|
|
158
|
+
current = getattr(args, name)
|
|
159
|
+
# Only apply if still at argparse default
|
|
160
|
+
if name == "graceful_timeout" and float(current) == 3.0:
|
|
161
|
+
setattr(args, name, float(cfg[key]))
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
_set_bool("yes", "yes")
|
|
166
|
+
_set_bool("dry_run", "dry_run")
|
|
167
|
+
_set_bool("json", "json")
|
|
168
|
+
_set_bool("debug", "debug")
|
|
169
|
+
_set_bool("force", "force")
|
|
170
|
+
_set_num("graceful_timeout", "graceful_timeout")
|
|
171
|
+
|
|
172
|
+
if hasattr(args, "docker_action") and getattr(args, "docker_action", None) is None:
|
|
173
|
+
v = cfg.get("docker_action")
|
|
174
|
+
if v in ("stop", "restart", "rm"):
|
|
175
|
+
setattr(args, "docker_action", v)
|
|
176
|
+
|
|
177
|
+
# Validation helpers
|
|
178
|
+
def validate_port(port: int) -> None:
|
|
179
|
+
if not (1 <= port <= 65535):
|
|
180
|
+
print(colorize(f"Error: Port {port} is not valid. Must be 1-65535.", Colors.RED), file=sys.stderr)
|
|
181
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
182
|
+
|
|
183
|
+
def parse_port_range(port_range: str, max_ports: int = 1000) -> List[int]:
|
|
184
|
+
"""
|
|
185
|
+
Parse a port or range string:
|
|
186
|
+
- "8080" -> [8080]
|
|
187
|
+
- "3000-3010" -> [3000..3010] (limit enforced)
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
if '-' in port_range:
|
|
191
|
+
start_s, end_s = port_range.split('-', 1)
|
|
192
|
+
start = int(start_s.strip())
|
|
193
|
+
end = int(end_s.strip())
|
|
194
|
+
if start > end:
|
|
195
|
+
print(colorize(f"Error: invalid range {port_range}: start > end", Colors.RED), file=sys.stderr)
|
|
196
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
197
|
+
total = end - start + 1
|
|
198
|
+
if total > max_ports:
|
|
199
|
+
print(colorize(f"Error: range too large ({total} ports). Maximum {max_ports} allowed.", Colors.RED), file=sys.stderr)
|
|
200
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
201
|
+
for p in (start, end):
|
|
202
|
+
validate_port(p)
|
|
203
|
+
return list(range(start, end + 1))
|
|
204
|
+
else:
|
|
205
|
+
port = int(port_range.strip())
|
|
206
|
+
validate_port(port)
|
|
207
|
+
return [port]
|
|
208
|
+
except ValueError:
|
|
209
|
+
print(colorize(f"Error: invalid port or range format: {port_range}", Colors.RED), file=sys.stderr)
|
|
210
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class ProcessInfo:
|
|
214
|
+
pid: int
|
|
215
|
+
name: str
|
|
216
|
+
exe: Optional[str] = None
|
|
217
|
+
cmdline: Optional[List[str]] = None
|
|
218
|
+
user: Optional[str] = None
|
|
219
|
+
|
|
220
|
+
@dataclass
|
|
221
|
+
class PortBinding:
|
|
222
|
+
port: int
|
|
223
|
+
family: str
|
|
224
|
+
laddr: str
|
|
225
|
+
pid: Optional[int] = None
|
|
226
|
+
process_name: Optional[str] = None
|
|
227
|
+
state: Optional[str] = None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass
|
|
231
|
+
class DockerPortMapping:
|
|
232
|
+
container_id: str
|
|
233
|
+
container_name: str
|
|
234
|
+
image: str
|
|
235
|
+
status: str
|
|
236
|
+
host_ip: Optional[str]
|
|
237
|
+
host_port: int
|
|
238
|
+
container_port: int
|
|
239
|
+
proto: str
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _run_docker(args: List[str], debug: bool = False) -> subprocess.CompletedProcess:
|
|
243
|
+
debug_log(debug, f"docker {' '.join(args)}")
|
|
244
|
+
return subprocess.run(["docker", *args], capture_output=True, text=True)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def docker_available() -> bool:
|
|
248
|
+
return check_dependency("docker")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def list_docker_mappings(debug: bool = False) -> List[DockerPortMapping]:
|
|
252
|
+
"""Return host-port mappings for running containers via `docker ps` + `docker port`.
|
|
253
|
+
|
|
254
|
+
This is intentionally CLI-based (no extra deps) and works on Linux/macOS/Windows
|
|
255
|
+
where Docker CLI is present.
|
|
256
|
+
"""
|
|
257
|
+
if not docker_available():
|
|
258
|
+
return []
|
|
259
|
+
|
|
260
|
+
ps = _run_docker(["ps", "--no-trunc", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}"], debug=debug)
|
|
261
|
+
if ps.returncode != 0:
|
|
262
|
+
debug_log(debug, f"docker ps failed: {ps.stderr.strip()}")
|
|
263
|
+
return []
|
|
264
|
+
|
|
265
|
+
mappings: List[DockerPortMapping] = []
|
|
266
|
+
for line in (ps.stdout or "").splitlines():
|
|
267
|
+
if not line.strip():
|
|
268
|
+
continue
|
|
269
|
+
parts = line.split("\t")
|
|
270
|
+
if len(parts) < 4:
|
|
271
|
+
continue
|
|
272
|
+
container_id, name, image, status = parts[0].strip(), parts[1].strip(), parts[2].strip(), parts[3].strip()
|
|
273
|
+
if not container_id:
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
port_out = _run_docker(["port", container_id], debug=debug)
|
|
277
|
+
if port_out.returncode != 0:
|
|
278
|
+
debug_log(debug, f"docker port {container_id} failed: {port_out.stderr.strip()}")
|
|
279
|
+
continue
|
|
280
|
+
for pline in (port_out.stdout or "").splitlines():
|
|
281
|
+
# Example lines:
|
|
282
|
+
# 80/tcp -> 0.0.0.0:8080
|
|
283
|
+
# 80/tcp -> :::8080
|
|
284
|
+
pline = pline.strip()
|
|
285
|
+
if not pline or "->" not in pline or "/" not in pline:
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
left, right = [p.strip() for p in pline.split("->", 1)]
|
|
289
|
+
# left: "80/tcp"
|
|
290
|
+
m = re.match(r"^(\d+)\/(tcp|udp)$", left)
|
|
291
|
+
if not m:
|
|
292
|
+
continue
|
|
293
|
+
container_port = int(m.group(1))
|
|
294
|
+
proto = m.group(2)
|
|
295
|
+
|
|
296
|
+
# right: "0.0.0.0:8080" or ":::8080" etc.
|
|
297
|
+
# Parse host port as last :<digits> segment.
|
|
298
|
+
host_ip: Optional[str] = None
|
|
299
|
+
host_port: Optional[int] = None
|
|
300
|
+
m2 = re.search(r":(\d+)$", right)
|
|
301
|
+
if not m2:
|
|
302
|
+
continue
|
|
303
|
+
try:
|
|
304
|
+
host_port = int(m2.group(1))
|
|
305
|
+
except Exception:
|
|
306
|
+
continue
|
|
307
|
+
host_ip = right[: right.rfind(":")].strip() or None
|
|
308
|
+
|
|
309
|
+
mappings.append(
|
|
310
|
+
DockerPortMapping(
|
|
311
|
+
container_id=container_id,
|
|
312
|
+
container_name=name,
|
|
313
|
+
image=image,
|
|
314
|
+
status=status,
|
|
315
|
+
host_ip=host_ip,
|
|
316
|
+
host_port=host_port,
|
|
317
|
+
container_port=container_port,
|
|
318
|
+
proto=proto,
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# De-duplicate (Docker can return IPv4 + IPv6 lines for same mapping)
|
|
323
|
+
seen = set()
|
|
324
|
+
uniq: List[DockerPortMapping] = []
|
|
325
|
+
for m in mappings:
|
|
326
|
+
# docker often reports the same published port once for IPv4 (0.0.0.0)
|
|
327
|
+
# and once for IPv6 (:::) — treat those as the same mapping for display.
|
|
328
|
+
key = (m.container_id, m.host_port, m.container_port, m.proto)
|
|
329
|
+
if key in seen:
|
|
330
|
+
continue
|
|
331
|
+
seen.add(key)
|
|
332
|
+
uniq.append(m)
|
|
333
|
+
|
|
334
|
+
# Sort by host port, then name
|
|
335
|
+
return sorted(uniq, key=lambda x: (x.host_port, x.container_name))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def docker_mappings_for_host_port(port: int, debug: bool = False) -> List[DockerPortMapping]:
|
|
339
|
+
return [m for m in list_docker_mappings(debug=debug) if m.host_port == port]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def docker_action_on_container(container_id: str, action: str, dry_run: bool, debug: bool = False) -> Tuple[bool, str]:
|
|
343
|
+
if dry_run:
|
|
344
|
+
return True, f"Dry-run: would docker {action} {container_id}"
|
|
345
|
+
if action == "stop":
|
|
346
|
+
r = _run_docker(["stop", container_id], debug=debug)
|
|
347
|
+
elif action == "restart":
|
|
348
|
+
r = _run_docker(["restart", container_id], debug=debug)
|
|
349
|
+
elif action == "rm":
|
|
350
|
+
r = _run_docker(["rm", "-f", container_id], debug=debug)
|
|
351
|
+
else:
|
|
352
|
+
return False, f"Unknown docker action: {action}"
|
|
353
|
+
if r.returncode == 0:
|
|
354
|
+
return True, (r.stdout or "").strip() or f"docker {action} succeeded"
|
|
355
|
+
return False, (r.stderr or r.stdout or "").strip() or f"docker {action} failed"
|
|
356
|
+
|
|
357
|
+
# Base inspector
|
|
358
|
+
class BaseInspector:
|
|
359
|
+
def list_listening(self) -> List[PortBinding]:
|
|
360
|
+
raise NotImplementedError()
|
|
361
|
+
|
|
362
|
+
def find_pids_on_port(self, port: int) -> List[int]:
|
|
363
|
+
raise NotImplementedError()
|
|
364
|
+
|
|
365
|
+
def get_process_info(self, pid: int) -> Optional[ProcessInfo]:
|
|
366
|
+
raise NotImplementedError()
|
|
367
|
+
|
|
368
|
+
def find_pids_by_name(self, name: str, exact: bool = False) -> List[int]:
|
|
369
|
+
raise NotImplementedError()
|
|
370
|
+
|
|
371
|
+
def find_ports_by_process_name(self, name: str, exact: bool = False) -> List[PortBinding]:
|
|
372
|
+
raise NotImplementedError()
|
|
373
|
+
|
|
374
|
+
def kill_pid(self, pid: int, graceful_timeout: float = 3.0, force: bool = False, dry_run: bool = False) -> Tuple[bool, str]:
|
|
375
|
+
"""
|
|
376
|
+
Attempt to kill process:
|
|
377
|
+
- Try graceful termination first (SIGTERM / terminate)
|
|
378
|
+
- Wait graceful_timeout seconds
|
|
379
|
+
- If still alive and force True, force kill (SIGKILL / taskkill / /F)
|
|
380
|
+
Returns (success, message)
|
|
381
|
+
"""
|
|
382
|
+
raise NotImplementedError()
|
|
383
|
+
|
|
384
|
+
# psutil-based inspector (best behavior cross-platform)
|
|
385
|
+
class PsutilInspector(BaseInspector):
|
|
386
|
+
def list_listening(self) -> List[PortBinding]:
|
|
387
|
+
bindings: Dict[Tuple[int, str], PortBinding] = {}
|
|
388
|
+
# net_connections returns many entries; filter relevant ones
|
|
389
|
+
for conn in psutil.net_connections(kind='inet'):
|
|
390
|
+
# laddr may be empty for some connection types
|
|
391
|
+
if not conn.laddr:
|
|
392
|
+
continue
|
|
393
|
+
laddr = f"{conn.laddr.ip}:{conn.laddr.port}" if hasattr(conn.laddr, 'ip') else f"{conn.laddr[0]}:{conn.laddr[1]}"
|
|
394
|
+
port = conn.laddr.port if hasattr(conn.laddr, 'port') else conn.laddr[1]
|
|
395
|
+
family = 'IPv6' if conn.family.name == 'AF_INET6' else 'IPv4'
|
|
396
|
+
state = conn.status
|
|
397
|
+
pid = conn.pid
|
|
398
|
+
key = (port, state)
|
|
399
|
+
proc_name = None
|
|
400
|
+
if pid:
|
|
401
|
+
try:
|
|
402
|
+
p = psutil.Process(pid)
|
|
403
|
+
proc_name = p.name()
|
|
404
|
+
except Exception:
|
|
405
|
+
proc_name = None
|
|
406
|
+
if (port, state) not in bindings:
|
|
407
|
+
bindings[(port, state)] = PortBinding(
|
|
408
|
+
port=port,
|
|
409
|
+
family=family,
|
|
410
|
+
laddr=laddr,
|
|
411
|
+
pid=pid,
|
|
412
|
+
process_name=proc_name,
|
|
413
|
+
state=state
|
|
414
|
+
)
|
|
415
|
+
# Return sorted by port
|
|
416
|
+
return sorted(bindings.values(), key=lambda b: b.port)
|
|
417
|
+
|
|
418
|
+
def find_pids_on_port(self, port: int) -> List[int]:
|
|
419
|
+
pids = set()
|
|
420
|
+
for conn in psutil.net_connections(kind='inet'):
|
|
421
|
+
if not conn.laddr:
|
|
422
|
+
continue
|
|
423
|
+
try:
|
|
424
|
+
conn_port = conn.laddr.port if hasattr(conn.laddr, 'port') else conn.laddr[1]
|
|
425
|
+
except Exception:
|
|
426
|
+
continue
|
|
427
|
+
if conn_port == port and conn.pid:
|
|
428
|
+
pids.add(conn.pid)
|
|
429
|
+
return sorted(pids)
|
|
430
|
+
|
|
431
|
+
def get_process_info(self, pid: int) -> Optional[ProcessInfo]:
|
|
432
|
+
try:
|
|
433
|
+
p = psutil.Process(pid)
|
|
434
|
+
return ProcessInfo(
|
|
435
|
+
pid=pid,
|
|
436
|
+
name=p.name(),
|
|
437
|
+
exe=p.exe() if p.exe() else None,
|
|
438
|
+
cmdline=p.cmdline() if p.cmdline() else None,
|
|
439
|
+
user=p.username() if hasattr(p, 'username') else None
|
|
440
|
+
)
|
|
441
|
+
except Exception:
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
def find_pids_by_name(self, name: str, exact: bool = False) -> List[int]:
|
|
445
|
+
out = []
|
|
446
|
+
name_lower = name.lower()
|
|
447
|
+
for p in psutil.process_iter(['pid', 'name', 'cmdline']):
|
|
448
|
+
try:
|
|
449
|
+
pname = (p.info['name'] or '')
|
|
450
|
+
if pname is None:
|
|
451
|
+
pname = ''
|
|
452
|
+
compare = pname.lower()
|
|
453
|
+
match = (compare == name_lower) if exact else (name_lower in compare or any(name_lower in (c or '').lower() for c in (p.info.get('cmdline') or [])))
|
|
454
|
+
if match:
|
|
455
|
+
out.append(p.info['pid'])
|
|
456
|
+
except Exception:
|
|
457
|
+
continue
|
|
458
|
+
return sorted(set(out))
|
|
459
|
+
|
|
460
|
+
def find_ports_by_process_name(self, name: str, exact: bool = False) -> List[PortBinding]:
|
|
461
|
+
results: List[PortBinding] = []
|
|
462
|
+
name_lower = name.lower()
|
|
463
|
+
for conn in psutil.net_connections(kind='inet'):
|
|
464
|
+
if not conn.laddr:
|
|
465
|
+
continue
|
|
466
|
+
pid = conn.pid
|
|
467
|
+
if not pid:
|
|
468
|
+
continue
|
|
469
|
+
try:
|
|
470
|
+
p = psutil.Process(pid)
|
|
471
|
+
pname = (p.name() or '').lower()
|
|
472
|
+
cmdline = ' '.join(p.cmdline() or []).lower()
|
|
473
|
+
matched = (pname == name_lower) if exact else (name_lower in pname or name_lower in cmdline)
|
|
474
|
+
if matched:
|
|
475
|
+
laddr = f"{conn.laddr.ip}:{conn.laddr.port}" if hasattr(conn.laddr, 'ip') else f"{conn.laddr[0]}:{conn.laddr[1]}"
|
|
476
|
+
family = 'IPv6' if conn.family.name == 'AF_INET6' else 'IPv4'
|
|
477
|
+
results.append(PortBinding(
|
|
478
|
+
port=conn.laddr.port if hasattr(conn.laddr, 'port') else conn.laddr[1],
|
|
479
|
+
family=family,
|
|
480
|
+
laddr=laddr,
|
|
481
|
+
pid=pid,
|
|
482
|
+
process_name=p.name(),
|
|
483
|
+
state=conn.status
|
|
484
|
+
))
|
|
485
|
+
except Exception:
|
|
486
|
+
continue
|
|
487
|
+
return sorted(results, key=lambda b: (b.pid or 0, b.port))
|
|
488
|
+
|
|
489
|
+
def kill_pid(self, pid: int, graceful_timeout: float = 3.0, force: bool = False, dry_run: bool = False) -> Tuple[bool, str]:
|
|
490
|
+
try:
|
|
491
|
+
proc = psutil.Process(pid)
|
|
492
|
+
except psutil.NoSuchProcess:
|
|
493
|
+
return False, "No such process"
|
|
494
|
+
except Exception as e:
|
|
495
|
+
return False, f"Failed to access process: {e}"
|
|
496
|
+
|
|
497
|
+
if dry_run:
|
|
498
|
+
return True, "Dry-run: would terminate process"
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
proc.terminate()
|
|
502
|
+
except psutil.AccessDenied:
|
|
503
|
+
return False, "Permission denied"
|
|
504
|
+
except Exception as e:
|
|
505
|
+
return False, f"Error terminating: {e}"
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
proc.wait(timeout=graceful_timeout)
|
|
509
|
+
return True, "Terminated gracefully"
|
|
510
|
+
except psutil.TimeoutExpired:
|
|
511
|
+
if not force:
|
|
512
|
+
return False, "Still running after graceful timeout"
|
|
513
|
+
# force kill
|
|
514
|
+
try:
|
|
515
|
+
proc.kill()
|
|
516
|
+
proc.wait(timeout=2)
|
|
517
|
+
return True, "Killed (force)"
|
|
518
|
+
except psutil.NoSuchProcess:
|
|
519
|
+
return True, "Process disappeared after kill"
|
|
520
|
+
except psutil.AccessDenied:
|
|
521
|
+
return False, "Permission denied on force kill"
|
|
522
|
+
except Exception as e:
|
|
523
|
+
return False, f"Error on force kill: {e}"
|
|
524
|
+
except Exception as e:
|
|
525
|
+
return False, f"Error waiting for termination: {e}"
|
|
526
|
+
|
|
527
|
+
# Fallback inspector using shell utilities (safe subprocess calls without shell=True)
|
|
528
|
+
class FallbackInspector(BaseInspector):
|
|
529
|
+
def __init__(self):
|
|
530
|
+
self.system = platform.system()
|
|
531
|
+
self._ps_exe = None
|
|
532
|
+
if self.system == "Windows":
|
|
533
|
+
self._ps_exe = shutil.which("powershell") or shutil.which("pwsh")
|
|
534
|
+
|
|
535
|
+
def _powershell(self) -> Optional[str]:
|
|
536
|
+
return self._ps_exe
|
|
537
|
+
|
|
538
|
+
def _run_powershell_json(self, script: str) -> Optional[Any]:
|
|
539
|
+
ps = self._powershell()
|
|
540
|
+
if not ps:
|
|
541
|
+
return None
|
|
542
|
+
try:
|
|
543
|
+
cmd = [ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script]
|
|
544
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
545
|
+
if proc.returncode != 0:
|
|
546
|
+
return None
|
|
547
|
+
out = (proc.stdout or "").strip()
|
|
548
|
+
if not out:
|
|
549
|
+
return None
|
|
550
|
+
return json.loads(out)
|
|
551
|
+
except Exception:
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
def list_listening(self) -> List[PortBinding]:
|
|
555
|
+
if self.system == "Windows":
|
|
556
|
+
return self._windows_listening()
|
|
557
|
+
else:
|
|
558
|
+
return self._unix_listening()
|
|
559
|
+
|
|
560
|
+
def _windows_listening(self) -> List[PortBinding]:
|
|
561
|
+
bindings: List[PortBinding] = []
|
|
562
|
+
# Prefer PowerShell (PRODUCT.md Phase 2) when available
|
|
563
|
+
ps_data = self._run_powershell_json(
|
|
564
|
+
"Get-NetTCPConnection -State Listen | Select-Object LocalAddress,LocalPort,OwningProcess,State | ConvertTo-Json -Depth 3"
|
|
565
|
+
)
|
|
566
|
+
if ps_data is not None:
|
|
567
|
+
items = ps_data if isinstance(ps_data, list) else [ps_data]
|
|
568
|
+
for it in items:
|
|
569
|
+
try:
|
|
570
|
+
port = int(it.get("LocalPort"))
|
|
571
|
+
except Exception:
|
|
572
|
+
continue
|
|
573
|
+
pid = None
|
|
574
|
+
try:
|
|
575
|
+
pid = int(it.get("OwningProcess"))
|
|
576
|
+
except Exception:
|
|
577
|
+
pid = None
|
|
578
|
+
laddr = f"{it.get('LocalAddress')}:{port}"
|
|
579
|
+
state = it.get("State")
|
|
580
|
+
pname = None
|
|
581
|
+
if pid:
|
|
582
|
+
info = self.get_process_info(pid)
|
|
583
|
+
pname = info.name if info else None
|
|
584
|
+
bindings.append(PortBinding(port=port, family='IPv4', laddr=laddr, pid=pid, process_name=pname, state=state))
|
|
585
|
+
return sorted(bindings, key=lambda b: b.port)
|
|
586
|
+
|
|
587
|
+
# Fallback to `netstat -ano` and `tasklist` for process names
|
|
588
|
+
if not check_dependency("netstat"):
|
|
589
|
+
print(colorize("Error: netstat not found on PATH.", Colors.RED), file=sys.stderr)
|
|
590
|
+
return bindings
|
|
591
|
+
try:
|
|
592
|
+
proc = subprocess.run(["netstat", "-ano"], capture_output=True, text=True)
|
|
593
|
+
lines = proc.stdout.splitlines()
|
|
594
|
+
for line in lines:
|
|
595
|
+
line = line.strip()
|
|
596
|
+
if not line:
|
|
597
|
+
continue
|
|
598
|
+
# Typical netstat line: TCP 0.0.0.0:80 0.0.0.0:0 LISTENING 1234
|
|
599
|
+
parts = re.split(r'\s+', line)
|
|
600
|
+
if len(parts) >= 5 and parts[0].upper() in ("TCP", "UDP"):
|
|
601
|
+
proto = parts[0]
|
|
602
|
+
local_addr = parts[1]
|
|
603
|
+
state = parts[3] if len(parts) >= 5 else ""
|
|
604
|
+
pid = None
|
|
605
|
+
try:
|
|
606
|
+
pid = int(parts[-1])
|
|
607
|
+
except Exception:
|
|
608
|
+
pid = None
|
|
609
|
+
# Extract port if local_addr contains :
|
|
610
|
+
if ':' in local_addr:
|
|
611
|
+
port_str = local_addr.rsplit(':', 1)[-1]
|
|
612
|
+
try:
|
|
613
|
+
port = int(port_str)
|
|
614
|
+
except ValueError:
|
|
615
|
+
continue
|
|
616
|
+
pname = None
|
|
617
|
+
if pid:
|
|
618
|
+
info = self.get_process_info(pid)
|
|
619
|
+
pname = info.name if info else None
|
|
620
|
+
bindings.append(PortBinding(port=port, family='IPv4', laddr=local_addr, pid=pid, process_name=pname, state=state))
|
|
621
|
+
except Exception:
|
|
622
|
+
pass
|
|
623
|
+
return sorted(bindings, key=lambda b: b.port)
|
|
624
|
+
|
|
625
|
+
def _unix_listening(self) -> List[PortBinding]:
|
|
626
|
+
bindings: List[PortBinding] = []
|
|
627
|
+
# Prefer lsof if available
|
|
628
|
+
if check_dependency("lsof"):
|
|
629
|
+
try:
|
|
630
|
+
proc = subprocess.run(["lsof", "-i", "-P", "-n"], capture_output=True, text=True)
|
|
631
|
+
lines = proc.stdout.splitlines()
|
|
632
|
+
for line in lines:
|
|
633
|
+
if "LISTEN" not in line and "LISTENING" not in line:
|
|
634
|
+
continue
|
|
635
|
+
parts = re.split(r'\s+', line)
|
|
636
|
+
# Format often: COMMAND PID USER ... NAME
|
|
637
|
+
if len(parts) < 9:
|
|
638
|
+
continue
|
|
639
|
+
command = parts[0]
|
|
640
|
+
pid = None
|
|
641
|
+
user = parts[2] if len(parts) > 2 else None
|
|
642
|
+
try:
|
|
643
|
+
pid = int(parts[1])
|
|
644
|
+
except Exception:
|
|
645
|
+
pid = None
|
|
646
|
+
name_field = parts[8]
|
|
647
|
+
# address may be like *:8080 or 127.0.0.1:8080
|
|
648
|
+
if ':' in name_field:
|
|
649
|
+
port = None
|
|
650
|
+
try:
|
|
651
|
+
port = int(name_field.rsplit(':', 1)[-1])
|
|
652
|
+
except Exception:
|
|
653
|
+
continue
|
|
654
|
+
bindings.append(PortBinding(port=port, family='IPv4', laddr=name_field, pid=pid, process_name=command, state="LISTEN"))
|
|
655
|
+
except Exception:
|
|
656
|
+
pass
|
|
657
|
+
else:
|
|
658
|
+
# fallback to ss if available
|
|
659
|
+
if check_dependency("ss"):
|
|
660
|
+
try:
|
|
661
|
+
proc = subprocess.run(["ss", "-ltnp"], capture_output=True, text=True)
|
|
662
|
+
lines = proc.stdout.splitlines()
|
|
663
|
+
# parse lines for LISTEN
|
|
664
|
+
for line in lines:
|
|
665
|
+
if "LISTEN" not in line:
|
|
666
|
+
continue
|
|
667
|
+
# example: LISTEN 0 128 127.0.0.1:8080 0.0.0.0:* users:(("python3",pid=1234,fd=3))
|
|
668
|
+
parts = re.split(r'\s+', line)
|
|
669
|
+
for token in parts:
|
|
670
|
+
if ':' in token and re.search(r':\d+$', token):
|
|
671
|
+
try:
|
|
672
|
+
port = int(token.rsplit(':', 1)[-1])
|
|
673
|
+
# pid parse from users:(("name",pid=1234,fd=3))
|
|
674
|
+
m = re.search(r'pid=(\d+)', line)
|
|
675
|
+
pid = int(m.group(1)) if m else None
|
|
676
|
+
pname = None
|
|
677
|
+
if pid:
|
|
678
|
+
info = self.get_process_info(pid)
|
|
679
|
+
pname = info.name if info else None
|
|
680
|
+
bindings.append(PortBinding(port=port, family='IPv4', laddr=token, pid=pid, process_name=pname, state="LISTEN"))
|
|
681
|
+
break
|
|
682
|
+
except Exception:
|
|
683
|
+
continue
|
|
684
|
+
except Exception:
|
|
685
|
+
pass
|
|
686
|
+
return sorted(bindings, key=lambda b: b.port)
|
|
687
|
+
|
|
688
|
+
def find_pids_on_port(self, port: int) -> List[int]:
|
|
689
|
+
if self.system == "Windows":
|
|
690
|
+
return self._windows_pids_on_port(port)
|
|
691
|
+
else:
|
|
692
|
+
return self._unix_pids_on_port(port)
|
|
693
|
+
|
|
694
|
+
def _windows_pids_on_port(self, port: int) -> List[int]:
|
|
695
|
+
pids = set()
|
|
696
|
+
# Prefer PowerShell
|
|
697
|
+
ps_data = self._run_powershell_json(
|
|
698
|
+
f"Get-NetTCPConnection -State Listen -LocalPort {port} | Select-Object -ExpandProperty OwningProcess | ConvertTo-Json -Depth 2"
|
|
699
|
+
)
|
|
700
|
+
if ps_data is not None:
|
|
701
|
+
if isinstance(ps_data, list):
|
|
702
|
+
for v in ps_data:
|
|
703
|
+
try:
|
|
704
|
+
pids.add(int(v))
|
|
705
|
+
except Exception:
|
|
706
|
+
continue
|
|
707
|
+
else:
|
|
708
|
+
try:
|
|
709
|
+
pids.add(int(ps_data))
|
|
710
|
+
except Exception:
|
|
711
|
+
pass
|
|
712
|
+
return sorted(pids)
|
|
713
|
+
if not check_dependency("netstat"):
|
|
714
|
+
return []
|
|
715
|
+
proc = subprocess.run(["netstat", "-ano"], capture_output=True, text=True)
|
|
716
|
+
for line in proc.stdout.splitlines():
|
|
717
|
+
parts = re.split(r'\s+', line.strip())
|
|
718
|
+
if len(parts) >= 5:
|
|
719
|
+
local_addr = parts[1]
|
|
720
|
+
if ':' in local_addr and local_addr.rsplit(':', 1)[-1] == str(port):
|
|
721
|
+
try:
|
|
722
|
+
pid = int(parts[-1])
|
|
723
|
+
pids.add(pid)
|
|
724
|
+
except Exception:
|
|
725
|
+
continue
|
|
726
|
+
return sorted(pids)
|
|
727
|
+
|
|
728
|
+
def _unix_pids_on_port(self, port: int) -> List[int]:
|
|
729
|
+
pids = set()
|
|
730
|
+
# Prefer lsof
|
|
731
|
+
if check_dependency("lsof"):
|
|
732
|
+
proc = subprocess.run(["lsof", "-t", "-i", f":{port}"], capture_output=True, text=True)
|
|
733
|
+
for line in proc.stdout.splitlines():
|
|
734
|
+
try:
|
|
735
|
+
pids.add(int(line.strip()))
|
|
736
|
+
except Exception:
|
|
737
|
+
continue
|
|
738
|
+
else:
|
|
739
|
+
# fallback to ss/grep netstat parsing
|
|
740
|
+
if check_dependency("ss"):
|
|
741
|
+
proc = subprocess.run(["ss", "-ltnp"], capture_output=True, text=True)
|
|
742
|
+
for line in proc.stdout.splitlines():
|
|
743
|
+
if f":{port} " in line or f":{port}\n" in line:
|
|
744
|
+
m = re.search(r'pid=(\d+)', line)
|
|
745
|
+
if m:
|
|
746
|
+
try:
|
|
747
|
+
pids.add(int(m.group(1)))
|
|
748
|
+
except Exception:
|
|
749
|
+
continue
|
|
750
|
+
return sorted(pids)
|
|
751
|
+
|
|
752
|
+
def get_process_info(self, pid: int) -> Optional[ProcessInfo]:
|
|
753
|
+
try:
|
|
754
|
+
if self.system == "Windows":
|
|
755
|
+
# Prefer PowerShell
|
|
756
|
+
ps_data = self._run_powershell_json(
|
|
757
|
+
f"Get-Process -Id {pid} | Select-Object Id,ProcessName,Path | ConvertTo-Json -Depth 3"
|
|
758
|
+
)
|
|
759
|
+
if isinstance(ps_data, dict):
|
|
760
|
+
name = ps_data.get("ProcessName")
|
|
761
|
+
exe = ps_data.get("Path")
|
|
762
|
+
if name:
|
|
763
|
+
return ProcessInfo(pid=pid, name=str(name), exe=str(exe) if exe else None)
|
|
764
|
+
|
|
765
|
+
# fallback: tasklist
|
|
766
|
+
proc = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"], capture_output=True, text=True)
|
|
767
|
+
out = proc.stdout.strip()
|
|
768
|
+
if not out:
|
|
769
|
+
return None
|
|
770
|
+
# Format: "Image Name","PID","Session Name","Session#","Mem Usage"
|
|
771
|
+
parts = [p.strip().strip('"') for p in re.split(r',(?=(?:[^"]*"[^"]*")*[^"]*$)', out)]
|
|
772
|
+
if parts:
|
|
773
|
+
name = parts[0]
|
|
774
|
+
return ProcessInfo(pid=pid, name=name)
|
|
775
|
+
else:
|
|
776
|
+
# Unix: use ps
|
|
777
|
+
proc = subprocess.run(["ps", "-p", str(pid), "-o", "pid=,comm=,user=,args="], capture_output=True, text=True)
|
|
778
|
+
out = proc.stdout.strip()
|
|
779
|
+
if not out:
|
|
780
|
+
return None
|
|
781
|
+
# Attempt parsing
|
|
782
|
+
parts = re.split(r'\s+', out, maxsplit=2)
|
|
783
|
+
if len(parts) >= 2:
|
|
784
|
+
name = parts[1]
|
|
785
|
+
user = parts[2].split()[0] if len(parts) >= 3 else None
|
|
786
|
+
return ProcessInfo(pid=pid, name=name, user=user)
|
|
787
|
+
except Exception:
|
|
788
|
+
return None
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
def find_pids_by_name(self, name: str, exact: bool = False) -> List[int]:
|
|
792
|
+
if self.system == "Windows":
|
|
793
|
+
# tasklist with filter
|
|
794
|
+
# tasklist /FI "IMAGENAME eq name*" may be used; use tasklist and filter in python for robustness
|
|
795
|
+
proc = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True)
|
|
796
|
+
out = proc.stdout or ""
|
|
797
|
+
pids = []
|
|
798
|
+
name_lower = name.lower()
|
|
799
|
+
for line in out.splitlines():
|
|
800
|
+
parts = [p.strip().strip('"') for p in re.split(r',(?=(?:[^"]*"[^"]*")*[^"]*$)', line)]
|
|
801
|
+
if len(parts) >= 2:
|
|
802
|
+
pname = parts[0]
|
|
803
|
+
pid_s = parts[1]
|
|
804
|
+
try:
|
|
805
|
+
pid = int(pid_s)
|
|
806
|
+
except Exception:
|
|
807
|
+
continue
|
|
808
|
+
match = (pname.lower() == name_lower) if exact else (name_lower in pname.lower())
|
|
809
|
+
if match:
|
|
810
|
+
pids.append(pid)
|
|
811
|
+
return sorted(pids)
|
|
812
|
+
else:
|
|
813
|
+
# use pgrep if available else ps/gawk
|
|
814
|
+
if check_dependency("pgrep"):
|
|
815
|
+
args = ["pgrep", "-f", name] if not exact else ["pgrep", "-x", name]
|
|
816
|
+
proc = subprocess.run(args, capture_output=True, text=True)
|
|
817
|
+
out = proc.stdout or ""
|
|
818
|
+
pids = []
|
|
819
|
+
for line in out.splitlines():
|
|
820
|
+
try:
|
|
821
|
+
pids.append(int(line.strip()))
|
|
822
|
+
except Exception:
|
|
823
|
+
continue
|
|
824
|
+
return sorted(pids)
|
|
825
|
+
else:
|
|
826
|
+
# fallback to ps -ef
|
|
827
|
+
proc = subprocess.run(["ps", "-ef"], capture_output=True, text=True)
|
|
828
|
+
out = proc.stdout or ""
|
|
829
|
+
pids = []
|
|
830
|
+
for line in out.splitlines():
|
|
831
|
+
if name in line if exact else name.lower() in line.lower():
|
|
832
|
+
parts = re.split(r'\s+', line.strip())
|
|
833
|
+
if len(parts) >= 2:
|
|
834
|
+
try:
|
|
835
|
+
pids.append(int(parts[1]))
|
|
836
|
+
except Exception:
|
|
837
|
+
continue
|
|
838
|
+
return sorted(set(pids))
|
|
839
|
+
|
|
840
|
+
def find_ports_by_process_name(self, name: str, exact: bool = False) -> List[PortBinding]:
|
|
841
|
+
results: List[PortBinding] = []
|
|
842
|
+
# Use lsof to map processes to ports if available
|
|
843
|
+
if check_dependency("lsof"):
|
|
844
|
+
try:
|
|
845
|
+
proc = subprocess.run(["lsof", "-i", "-P", "-n"], capture_output=True, text=True)
|
|
846
|
+
out = proc.stdout or ""
|
|
847
|
+
for line in out.splitlines():
|
|
848
|
+
if name.lower() not in line.lower() and (exact and name not in line):
|
|
849
|
+
continue
|
|
850
|
+
parts = re.split(r'\s+', line)
|
|
851
|
+
if len(parts) < 9:
|
|
852
|
+
continue
|
|
853
|
+
command = parts[0]
|
|
854
|
+
pid_s = parts[1]
|
|
855
|
+
try:
|
|
856
|
+
pid = int(pid_s)
|
|
857
|
+
except Exception:
|
|
858
|
+
pid = None
|
|
859
|
+
addr = parts[8]
|
|
860
|
+
# addr like *:8080
|
|
861
|
+
if ':' in addr:
|
|
862
|
+
try:
|
|
863
|
+
port = int(addr.rsplit(':', 1)[-1])
|
|
864
|
+
except Exception:
|
|
865
|
+
continue
|
|
866
|
+
results.append(PortBinding(port=port, family='IPv4', laddr=addr, pid=pid, process_name=command, state="LISTEN" if "LISTEN" in line else None))
|
|
867
|
+
except Exception:
|
|
868
|
+
pass
|
|
869
|
+
else:
|
|
870
|
+
# fallback: find pids then find their ports
|
|
871
|
+
pids = self.find_pids_by_name(name, exact)
|
|
872
|
+
for pid in pids:
|
|
873
|
+
# try lsof -p <pid> -i
|
|
874
|
+
if check_dependency("lsof"):
|
|
875
|
+
proc = subprocess.run(["lsof", "-a", "-p", str(pid), "-i", "-P", "-n"], capture_output=True, text=True)
|
|
876
|
+
out = proc.stdout or ""
|
|
877
|
+
for line in out.splitlines():
|
|
878
|
+
if "LISTEN" not in line and "TCP" not in line and "UDP" not in line:
|
|
879
|
+
continue
|
|
880
|
+
parts = re.split(r'\s+', line)
|
|
881
|
+
if len(parts) >= 9:
|
|
882
|
+
addr = parts[8]
|
|
883
|
+
try:
|
|
884
|
+
port = int(addr.rsplit(':', 1)[-1])
|
|
885
|
+
except Exception:
|
|
886
|
+
continue
|
|
887
|
+
results.append(PortBinding(port=port, family='IPv4', laddr=addr, pid=pid, process_name=parts[0], state="LISTEN"))
|
|
888
|
+
return sorted(results, key=lambda b: (b.pid or 0, b.port))
|
|
889
|
+
|
|
890
|
+
def kill_pid(self, pid: int, graceful_timeout: float = 3.0, force: bool = False, dry_run: bool = False) -> Tuple[bool, str]:
|
|
891
|
+
if dry_run:
|
|
892
|
+
return True, "Dry-run: would attempt terminate"
|
|
893
|
+
try:
|
|
894
|
+
if self.system == "Windows":
|
|
895
|
+
# taskkill without /F is "gentle", with /F is forced
|
|
896
|
+
try:
|
|
897
|
+
proc = subprocess.run(["taskkill", "/PID", str(pid)], capture_output=True, text=True)
|
|
898
|
+
if proc.returncode == 0:
|
|
899
|
+
return True, "Terminated (taskkill)"
|
|
900
|
+
except Exception:
|
|
901
|
+
pass
|
|
902
|
+
if force:
|
|
903
|
+
try:
|
|
904
|
+
proc = subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, text=True)
|
|
905
|
+
return (proc.returncode == 0), proc.stdout + proc.stderr
|
|
906
|
+
except Exception as e:
|
|
907
|
+
return False, f"Error taskkill: {e}"
|
|
908
|
+
return False, "Still running; taskkill gentle failed"
|
|
909
|
+
else:
|
|
910
|
+
# Unix: try SIGTERM then SIGKILL
|
|
911
|
+
try:
|
|
912
|
+
os.kill(pid, signal.SIGTERM)
|
|
913
|
+
except PermissionError:
|
|
914
|
+
return False, "Permission denied"
|
|
915
|
+
except ProcessLookupError:
|
|
916
|
+
return False, "No such process"
|
|
917
|
+
except Exception as e:
|
|
918
|
+
return False, f"Error sending SIGTERM: {e}"
|
|
919
|
+
# wait short time
|
|
920
|
+
waited = 0.0
|
|
921
|
+
interval = 0.1
|
|
922
|
+
while waited < graceful_timeout:
|
|
923
|
+
time.sleep(interval)
|
|
924
|
+
waited += interval
|
|
925
|
+
# check if process exists
|
|
926
|
+
try:
|
|
927
|
+
os.kill(pid, 0)
|
|
928
|
+
# still alive
|
|
929
|
+
except ProcessLookupError:
|
|
930
|
+
return True, "Terminated gracefully"
|
|
931
|
+
except PermissionError:
|
|
932
|
+
return False, "Permission denied"
|
|
933
|
+
if not force:
|
|
934
|
+
return False, "Still alive after graceful timeout"
|
|
935
|
+
# force kill
|
|
936
|
+
try:
|
|
937
|
+
os.kill(pid, signal.SIGKILL)
|
|
938
|
+
return True, "Killed (SIGKILL)"
|
|
939
|
+
except PermissionError:
|
|
940
|
+
return False, "Permission denied on SIGKILL"
|
|
941
|
+
except ProcessLookupError:
|
|
942
|
+
return True, "Process disappeared after SIGKILL"
|
|
943
|
+
except Exception as e:
|
|
944
|
+
return False, f"Error SIGKILL: {e}"
|
|
945
|
+
except Exception as e:
|
|
946
|
+
return False, f"Unexpected error: {e}"
|
|
947
|
+
|
|
948
|
+
# Factory
|
|
949
|
+
def get_inspector() -> BaseInspector:
|
|
950
|
+
if USING_PSUTIL:
|
|
951
|
+
return PsutilInspector()
|
|
952
|
+
else:
|
|
953
|
+
return FallbackInspector()
|
|
954
|
+
|
|
955
|
+
# CLI and orchestration
|
|
956
|
+
def print_table_listen(bindings: List[PortBinding]) -> None:
|
|
957
|
+
if not bindings:
|
|
958
|
+
print(colorize("No listening ports found.", Colors.YELLOW))
|
|
959
|
+
return
|
|
960
|
+
print(colorize(f"{'Port':<8} {'PID':<8} {'Process':<25} {'State':<12} {'Address':<25}", Colors.BOLD))
|
|
961
|
+
print("─" * 80)
|
|
962
|
+
for b in bindings:
|
|
963
|
+
pid = str(b.pid) if b.pid is not None else "-"
|
|
964
|
+
pname = b.process_name or "-"
|
|
965
|
+
state = b.state or "-"
|
|
966
|
+
print(f"{colorize(str(b.port), Colors.CYAN):<8} {pid:<8} {pname:<25} {state:<12} {b.laddr:<25}")
|
|
967
|
+
|
|
968
|
+
def jsonify_bindings(bindings: List[PortBinding]) -> str:
|
|
969
|
+
return json.dumps([asdict(b) for b in bindings], indent=2)
|
|
970
|
+
|
|
971
|
+
def confirm_prompt(prompt: str, assume_yes: bool = False) -> bool:
|
|
972
|
+
if assume_yes:
|
|
973
|
+
return True
|
|
974
|
+
try:
|
|
975
|
+
resp = input(colorize(prompt + " (y/N): ", Colors.MAGENTA))
|
|
976
|
+
return resp.strip().lower() in ("y", "yes")
|
|
977
|
+
except KeyboardInterrupt:
|
|
978
|
+
print(colorize("\nOperation cancelled.", Colors.YELLOW))
|
|
979
|
+
sys.exit(EXIT_GENERAL_ERROR)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def choose_docker_action(assume_yes: bool) -> Optional[str]:
|
|
983
|
+
"""Interactive docker action chooser. Returns action or None (cancel)."""
|
|
984
|
+
if assume_yes:
|
|
985
|
+
# safe default when user explicitly asked to skip prompts
|
|
986
|
+
return "stop"
|
|
987
|
+
print(colorize("\nChoose action:\n1) Stop container\n2) Restart container\n3) Remove container\n4) Cancel", Colors.CYAN))
|
|
988
|
+
try:
|
|
989
|
+
resp = input(colorize("Select (1-4): ", Colors.MAGENTA)).strip()
|
|
990
|
+
except KeyboardInterrupt:
|
|
991
|
+
print(colorize("\nOperation cancelled.", Colors.YELLOW))
|
|
992
|
+
return None
|
|
993
|
+
mapping = {"1": "stop", "2": "restart", "3": "rm", "4": None}
|
|
994
|
+
return mapping.get(resp)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def print_table_docker(mappings: List[DockerPortMapping]) -> None:
|
|
998
|
+
if not mappings:
|
|
999
|
+
print(colorize("No Docker-published ports found.", Colors.YELLOW))
|
|
1000
|
+
return
|
|
1001
|
+
print(colorize(f"{'PORT':<8} {'CONTAINER':<20} {'IMAGE':<25} {'STATUS':<20}", Colors.BOLD))
|
|
1002
|
+
print("─" * 80)
|
|
1003
|
+
for m in mappings:
|
|
1004
|
+
print(f"{colorize(str(m.host_port), Colors.CYAN):<8} {m.container_name:<20} {m.image:<25} {m.status:<20}")
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def print_table_list_product(local_bindings: List[PortBinding], docker_maps: List[DockerPortMapping]) -> None:
|
|
1008
|
+
"""Product-style list output: PORT TYPE OWNER."""
|
|
1009
|
+
rows: Dict[int, Dict[str, Any]] = {}
|
|
1010
|
+
for b in local_bindings:
|
|
1011
|
+
rows.setdefault(b.port, {})
|
|
1012
|
+
rows[b.port]["local"] = b
|
|
1013
|
+
for d in docker_maps:
|
|
1014
|
+
rows.setdefault(d.host_port, {})
|
|
1015
|
+
rows[d.host_port]["docker"] = d
|
|
1016
|
+
|
|
1017
|
+
if not rows:
|
|
1018
|
+
print(colorize("No active ports found.", Colors.YELLOW))
|
|
1019
|
+
return
|
|
1020
|
+
print(colorize(f"{'PORT':<8} {'TYPE':<10} {'OWNER':<25}", Colors.BOLD))
|
|
1021
|
+
print("─" * 55)
|
|
1022
|
+
for port in sorted(rows.keys()):
|
|
1023
|
+
if "docker" in rows[port] and "local" in rows[port]:
|
|
1024
|
+
owner = (rows[port]["docker"].container_name)
|
|
1025
|
+
print(f"{colorize(str(port), Colors.CYAN):<8} {'conflict':<10} {owner:<25}")
|
|
1026
|
+
elif "docker" in rows[port]:
|
|
1027
|
+
owner = rows[port]["docker"].container_name
|
|
1028
|
+
print(f"{colorize(str(port), Colors.CYAN):<8} {'docker':<10} {owner:<25}")
|
|
1029
|
+
else:
|
|
1030
|
+
b = rows[port]["local"]
|
|
1031
|
+
owner = b.process_name or "-"
|
|
1032
|
+
print(f"{colorize(str(port), Colors.CYAN):<8} {'local':<10} {owner:<25}")
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def jsonify_docker(mappings: List[DockerPortMapping]) -> str:
|
|
1036
|
+
return json.dumps([asdict(m) for m in mappings], indent=2)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def handle_product_command(args: argparse.Namespace, inspector: BaseInspector) -> int:
|
|
1040
|
+
"""Implements PRODUCT.md `kport <command>` interface."""
|
|
1041
|
+
debug = bool(getattr(args, "debug", False))
|
|
1042
|
+
|
|
1043
|
+
if args.command == "docker":
|
|
1044
|
+
maps = list_docker_mappings(debug=debug)
|
|
1045
|
+
if args.json:
|
|
1046
|
+
print(jsonify_docker(maps))
|
|
1047
|
+
else:
|
|
1048
|
+
print_table_docker(maps)
|
|
1049
|
+
return EXIT_OK
|
|
1050
|
+
|
|
1051
|
+
if args.command == "list":
|
|
1052
|
+
local = inspector.list_listening()
|
|
1053
|
+
docker_maps = list_docker_mappings(debug=debug)
|
|
1054
|
+
if args.json:
|
|
1055
|
+
# Provide both sources; consumer can merge.
|
|
1056
|
+
print(json.dumps({"local": [asdict(b) for b in local], "docker": [asdict(m) for m in docker_maps]}, indent=2))
|
|
1057
|
+
else:
|
|
1058
|
+
print_table_list_product(local, docker_maps)
|
|
1059
|
+
return EXIT_OK
|
|
1060
|
+
|
|
1061
|
+
if args.command == "inspect":
|
|
1062
|
+
validate_port(args.port)
|
|
1063
|
+
local_bindings = [b for b in inspector.list_listening() if b.port == args.port]
|
|
1064
|
+
docker_hits = docker_mappings_for_host_port(args.port, debug=debug)
|
|
1065
|
+
pids = inspector.find_pids_on_port(args.port)
|
|
1066
|
+
|
|
1067
|
+
if docker_hits:
|
|
1068
|
+
m = docker_hits[0]
|
|
1069
|
+
payload = {
|
|
1070
|
+
"port": args.port,
|
|
1071
|
+
"type": "docker",
|
|
1072
|
+
"container": m.container_name,
|
|
1073
|
+
"image": m.image,
|
|
1074
|
+
"host_port": m.host_port,
|
|
1075
|
+
"container_port": m.container_port,
|
|
1076
|
+
"status": m.status,
|
|
1077
|
+
}
|
|
1078
|
+
if args.json:
|
|
1079
|
+
print(json.dumps(payload, indent=2))
|
|
1080
|
+
else:
|
|
1081
|
+
print(colorize(f"Port: {args.port}", Colors.CYAN + Colors.BOLD))
|
|
1082
|
+
print("Type: Docker Container")
|
|
1083
|
+
print(f"Container: {m.container_name}")
|
|
1084
|
+
print(f"Image: {m.image}")
|
|
1085
|
+
print(f"Host Port: {m.host_port}")
|
|
1086
|
+
print(f"Container Port: {m.container_port}")
|
|
1087
|
+
print(f"Status: {m.status}")
|
|
1088
|
+
return EXIT_PORT_DOCKER
|
|
1089
|
+
|
|
1090
|
+
if not pids and not local_bindings:
|
|
1091
|
+
if args.json:
|
|
1092
|
+
print(json.dumps({"port": args.port, "type": "free"}, indent=2))
|
|
1093
|
+
else:
|
|
1094
|
+
print(colorize(f"Port {args.port} is free", Colors.GREEN))
|
|
1095
|
+
return EXIT_PORT_FREE
|
|
1096
|
+
|
|
1097
|
+
if not pids and local_bindings:
|
|
1098
|
+
# Port is listening, but OS did not provide PID (often needs elevated privileges)
|
|
1099
|
+
msg = "Port is in use, but the owning PID is not visible (try running with sudo/admin)."
|
|
1100
|
+
if args.json:
|
|
1101
|
+
print(json.dumps({"port": args.port, "type": "local-unknown", "message": msg, "bindings": [asdict(b) for b in local_bindings]}, indent=2))
|
|
1102
|
+
else:
|
|
1103
|
+
print(colorize(f"Port: {args.port}", Colors.CYAN + Colors.BOLD))
|
|
1104
|
+
print("Type: Local Process")
|
|
1105
|
+
print(colorize(msg, Colors.YELLOW))
|
|
1106
|
+
return EXIT_OK
|
|
1107
|
+
|
|
1108
|
+
# local process
|
|
1109
|
+
info_list = []
|
|
1110
|
+
for pid in pids:
|
|
1111
|
+
info = inspector.get_process_info(pid)
|
|
1112
|
+
info_list.append({"pid": pid, "process": asdict(info) if info else None})
|
|
1113
|
+
if args.json:
|
|
1114
|
+
print(json.dumps({"port": args.port, "type": "local", "pids": info_list}, indent=2))
|
|
1115
|
+
else:
|
|
1116
|
+
print(colorize(f"Port: {args.port}", Colors.CYAN + Colors.BOLD))
|
|
1117
|
+
print("Type: Local Process")
|
|
1118
|
+
for entry in info_list:
|
|
1119
|
+
pid = entry["pid"]
|
|
1120
|
+
proc = entry["process"]
|
|
1121
|
+
if proc:
|
|
1122
|
+
print(f"PID: {pid}")
|
|
1123
|
+
print(f"Process: {proc.get('name')}")
|
|
1124
|
+
if proc.get("cmdline"):
|
|
1125
|
+
print(f"Command: {' '.join(proc['cmdline'])}")
|
|
1126
|
+
else:
|
|
1127
|
+
print(f"PID: {pid} (info unavailable)")
|
|
1128
|
+
return EXIT_OK
|
|
1129
|
+
|
|
1130
|
+
if args.command == "explain":
|
|
1131
|
+
validate_port(args.port)
|
|
1132
|
+
local_bindings = [b for b in inspector.list_listening() if b.port == args.port]
|
|
1133
|
+
docker_hits = docker_mappings_for_host_port(args.port, debug=debug)
|
|
1134
|
+
if docker_hits:
|
|
1135
|
+
m = docker_hits[0]
|
|
1136
|
+
if args.json:
|
|
1137
|
+
print(
|
|
1138
|
+
json.dumps(
|
|
1139
|
+
{
|
|
1140
|
+
"port": args.port,
|
|
1141
|
+
"blocked": True,
|
|
1142
|
+
"because": [
|
|
1143
|
+
f"It is mapped to Docker container '{m.container_name}'",
|
|
1144
|
+
f"Docker maps host port {m.host_port} → container port {m.container_port}",
|
|
1145
|
+
"The process runs inside an isolated network namespace",
|
|
1146
|
+
],
|
|
1147
|
+
},
|
|
1148
|
+
indent=2,
|
|
1149
|
+
)
|
|
1150
|
+
)
|
|
1151
|
+
else:
|
|
1152
|
+
print(colorize(f"Port {args.port} is unavailable because:", Colors.YELLOW + Colors.BOLD))
|
|
1153
|
+
print(f"- It is mapped to Docker container \"{m.container_name}\"")
|
|
1154
|
+
print(f"- Docker maps host port {m.host_port} → container port {m.container_port}")
|
|
1155
|
+
print("- The process runs inside an isolated network namespace")
|
|
1156
|
+
return EXIT_PORT_DOCKER
|
|
1157
|
+
|
|
1158
|
+
pids = inspector.find_pids_on_port(args.port)
|
|
1159
|
+
if not pids and not local_bindings:
|
|
1160
|
+
if args.json:
|
|
1161
|
+
print(json.dumps({"port": args.port, "blocked": False}, indent=2))
|
|
1162
|
+
else:
|
|
1163
|
+
print(colorize(f"Port {args.port} is free", Colors.GREEN))
|
|
1164
|
+
return EXIT_PORT_FREE
|
|
1165
|
+
|
|
1166
|
+
if not pids and local_bindings:
|
|
1167
|
+
if args.json:
|
|
1168
|
+
print(json.dumps({"port": args.port, "blocked": True, "type": "local-unknown", "message": "Owning PID not visible (try sudo/admin)", "bindings": [asdict(b) for b in local_bindings]}, indent=2))
|
|
1169
|
+
else:
|
|
1170
|
+
print(colorize(f"Port {args.port} is unavailable because:", Colors.YELLOW + Colors.BOLD))
|
|
1171
|
+
print("- A local process is listening, but the owning PID is not visible")
|
|
1172
|
+
print("- This is commonly due to missing privileges; try running with sudo")
|
|
1173
|
+
return EXIT_OK
|
|
1174
|
+
|
|
1175
|
+
# local process explanation
|
|
1176
|
+
infos = []
|
|
1177
|
+
for pid in pids:
|
|
1178
|
+
info = inspector.get_process_info(pid)
|
|
1179
|
+
infos.append({"pid": pid, "process": asdict(info) if info else None})
|
|
1180
|
+
if args.json:
|
|
1181
|
+
print(json.dumps({"port": args.port, "blocked": True, "type": "local", "pids": infos}, indent=2))
|
|
1182
|
+
else:
|
|
1183
|
+
print(colorize(f"Port {args.port} is unavailable because:", Colors.YELLOW + Colors.BOLD))
|
|
1184
|
+
for entry in infos:
|
|
1185
|
+
proc = entry["process"]
|
|
1186
|
+
if proc:
|
|
1187
|
+
print(f"- PID {entry['pid']} ({proc.get('name')}) is listening")
|
|
1188
|
+
else:
|
|
1189
|
+
print(f"- PID {entry['pid']} is listening")
|
|
1190
|
+
return EXIT_OK
|
|
1191
|
+
|
|
1192
|
+
if args.command == "kill":
|
|
1193
|
+
validate_port(args.port)
|
|
1194
|
+
debug = bool(getattr(args, "debug", False))
|
|
1195
|
+
local_bindings = [b for b in inspector.list_listening() if b.port == args.port]
|
|
1196
|
+
docker_hits = docker_mappings_for_host_port(args.port, debug=debug)
|
|
1197
|
+
if docker_hits:
|
|
1198
|
+
m = docker_hits[0]
|
|
1199
|
+
action = getattr(args, "docker_action", None)
|
|
1200
|
+
if not action and not args.json:
|
|
1201
|
+
print(colorize(f"Port {args.port} belongs to Docker container: {m.container_name}", Colors.YELLOW + Colors.BOLD))
|
|
1202
|
+
action = choose_docker_action(assume_yes=args.yes)
|
|
1203
|
+
if not action:
|
|
1204
|
+
if args.json:
|
|
1205
|
+
print(
|
|
1206
|
+
json.dumps(
|
|
1207
|
+
{
|
|
1208
|
+
"port": args.port,
|
|
1209
|
+
"type": "docker",
|
|
1210
|
+
"container": m.container_name,
|
|
1211
|
+
"container_id": m.container_id,
|
|
1212
|
+
"available_actions": ["stop", "restart", "rm"],
|
|
1213
|
+
"performed": None,
|
|
1214
|
+
"message": "No action selected",
|
|
1215
|
+
},
|
|
1216
|
+
indent=2,
|
|
1217
|
+
)
|
|
1218
|
+
)
|
|
1219
|
+
else:
|
|
1220
|
+
print(colorize("Operation cancelled.", Colors.YELLOW))
|
|
1221
|
+
return EXIT_GENERAL_ERROR
|
|
1222
|
+
|
|
1223
|
+
if args.json and not args.yes and not args.dry_run:
|
|
1224
|
+
print(
|
|
1225
|
+
json.dumps(
|
|
1226
|
+
{
|
|
1227
|
+
"port": args.port,
|
|
1228
|
+
"type": "docker",
|
|
1229
|
+
"container": m.container_name,
|
|
1230
|
+
"container_id": m.container_id,
|
|
1231
|
+
"requested_action": action,
|
|
1232
|
+
"performed": False,
|
|
1233
|
+
"message": "Refusing to act without --yes in JSON mode",
|
|
1234
|
+
},
|
|
1235
|
+
indent=2,
|
|
1236
|
+
)
|
|
1237
|
+
)
|
|
1238
|
+
return EXIT_GENERAL_ERROR
|
|
1239
|
+
|
|
1240
|
+
ok, msg = docker_action_on_container(m.container_id, action=action, dry_run=args.dry_run, debug=debug)
|
|
1241
|
+
if args.json:
|
|
1242
|
+
print(
|
|
1243
|
+
json.dumps(
|
|
1244
|
+
{
|
|
1245
|
+
"port": args.port,
|
|
1246
|
+
"type": "docker",
|
|
1247
|
+
"container": m.container_name,
|
|
1248
|
+
"container_id": m.container_id,
|
|
1249
|
+
"action": action,
|
|
1250
|
+
"ok": ok,
|
|
1251
|
+
"message": msg,
|
|
1252
|
+
},
|
|
1253
|
+
indent=2,
|
|
1254
|
+
)
|
|
1255
|
+
)
|
|
1256
|
+
else:
|
|
1257
|
+
if ok:
|
|
1258
|
+
print(colorize(f"✓ {msg}", Colors.GREEN))
|
|
1259
|
+
else:
|
|
1260
|
+
print(colorize(f"✗ {msg}", Colors.RED))
|
|
1261
|
+
return EXIT_OK if ok else EXIT_GENERAL_ERROR
|
|
1262
|
+
|
|
1263
|
+
# local process kill
|
|
1264
|
+
pids = inspector.find_pids_on_port(args.port)
|
|
1265
|
+
if not pids and not local_bindings:
|
|
1266
|
+
if args.json:
|
|
1267
|
+
print(json.dumps({"port": args.port, "killed": [], "failed": [], "message": "Port free"}, indent=2))
|
|
1268
|
+
else:
|
|
1269
|
+
print(colorize(f"Port {args.port} is free", Colors.GREEN))
|
|
1270
|
+
return EXIT_PORT_FREE
|
|
1271
|
+
|
|
1272
|
+
if not pids and local_bindings:
|
|
1273
|
+
msg = "Port is in use but PID is not visible; cannot kill safely without PID. Try sudo/admin."
|
|
1274
|
+
if args.json:
|
|
1275
|
+
print(json.dumps({"port": args.port, "ok": False, "message": msg, "bindings": [asdict(b) for b in local_bindings]}, indent=2))
|
|
1276
|
+
else:
|
|
1277
|
+
print(colorize(msg, Colors.RED))
|
|
1278
|
+
return EXIT_PERMISSION
|
|
1279
|
+
|
|
1280
|
+
if not args.json:
|
|
1281
|
+
print(colorize("Action plan:\n1. Send SIGTERM\n2. Wait\n3. Escalate if needed", Colors.CYAN))
|
|
1282
|
+
if not confirm_prompt("Proceed?", assume_yes=args.yes):
|
|
1283
|
+
print(colorize("Operation cancelled.", Colors.YELLOW))
|
|
1284
|
+
return EXIT_GENERAL_ERROR
|
|
1285
|
+
|
|
1286
|
+
out_killed = []
|
|
1287
|
+
out_failed = []
|
|
1288
|
+
for pid in pids:
|
|
1289
|
+
ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
|
|
1290
|
+
if ok:
|
|
1291
|
+
out_killed.append({"pid": pid, "msg": msg})
|
|
1292
|
+
else:
|
|
1293
|
+
out_failed.append({"pid": pid, "msg": msg})
|
|
1294
|
+
if args.json:
|
|
1295
|
+
print(json.dumps({"port": args.port, "killed": out_killed, "failed": out_failed}, indent=2))
|
|
1296
|
+
else:
|
|
1297
|
+
for k in out_killed:
|
|
1298
|
+
print(colorize(f"✓ Killed PID {k['pid']} ({k['msg']})", Colors.GREEN))
|
|
1299
|
+
for f in out_failed:
|
|
1300
|
+
print(colorize(f"✗ Failed PID {f['pid']} ({f['msg']})", Colors.RED))
|
|
1301
|
+
return EXIT_OK if not out_failed else EXIT_GENERAL_ERROR
|
|
1302
|
+
|
|
1303
|
+
if args.command == "kill-process":
|
|
1304
|
+
pname = args.name
|
|
1305
|
+
pids = inspector.find_pids_by_name(pname, exact=args.exact)
|
|
1306
|
+
if not pids:
|
|
1307
|
+
if args.json:
|
|
1308
|
+
print(json.dumps({"name": pname, "pids": []}, indent=2))
|
|
1309
|
+
else:
|
|
1310
|
+
print(colorize(f"❌ No processes found matching '{pname}'", Colors.RED))
|
|
1311
|
+
return EXIT_OK
|
|
1312
|
+
if not args.json and not confirm_prompt(f"Proceed to terminate {len(pids)} process(es)?", assume_yes=args.yes):
|
|
1313
|
+
print(colorize("Operation cancelled.", Colors.YELLOW))
|
|
1314
|
+
return EXIT_GENERAL_ERROR
|
|
1315
|
+
killed = []
|
|
1316
|
+
failed = []
|
|
1317
|
+
for pid in pids:
|
|
1318
|
+
ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
|
|
1319
|
+
if ok:
|
|
1320
|
+
killed.append({"pid": pid, "msg": msg})
|
|
1321
|
+
else:
|
|
1322
|
+
failed.append({"pid": pid, "msg": msg})
|
|
1323
|
+
if args.json:
|
|
1324
|
+
print(json.dumps({"killed": killed, "failed": failed}, indent=2))
|
|
1325
|
+
else:
|
|
1326
|
+
for k in killed:
|
|
1327
|
+
print(colorize(f"✓ Killed PID {k['pid']} ({k['msg']})", Colors.GREEN))
|
|
1328
|
+
for f in failed:
|
|
1329
|
+
print(colorize(f"✗ Failed PID {f['pid']} ({f['msg']})", Colors.RED))
|
|
1330
|
+
return EXIT_OK if not failed else EXIT_GENERAL_ERROR
|
|
1331
|
+
|
|
1332
|
+
if args.command == "conflicts":
|
|
1333
|
+
docker_maps = list_docker_mappings(debug=debug)
|
|
1334
|
+
conflicts: List[Dict[str, Any]] = []
|
|
1335
|
+
for m in docker_maps:
|
|
1336
|
+
pids = inspector.find_pids_on_port(m.host_port)
|
|
1337
|
+
# Ignore the common docker-proxy holder; conflict means some *other* local process also binds.
|
|
1338
|
+
non_docker_pids = []
|
|
1339
|
+
for pid in pids:
|
|
1340
|
+
info = inspector.get_process_info(pid)
|
|
1341
|
+
pname = (info.name if info else "").lower()
|
|
1342
|
+
if "docker-proxy" in pname or pname.startswith("docker"):
|
|
1343
|
+
continue
|
|
1344
|
+
non_docker_pids.append({"pid": pid, "process": asdict(info) if info else None})
|
|
1345
|
+
if non_docker_pids:
|
|
1346
|
+
conflicts.append(
|
|
1347
|
+
{
|
|
1348
|
+
"port": m.host_port,
|
|
1349
|
+
"docker": asdict(m),
|
|
1350
|
+
"local": non_docker_pids,
|
|
1351
|
+
}
|
|
1352
|
+
)
|
|
1353
|
+
if args.json:
|
|
1354
|
+
print(json.dumps(conflicts, indent=2))
|
|
1355
|
+
else:
|
|
1356
|
+
if not conflicts:
|
|
1357
|
+
print(colorize("No port conflicts detected.", Colors.GREEN))
|
|
1358
|
+
else:
|
|
1359
|
+
print(colorize("WARNING: Port conflict detected", Colors.YELLOW + Colors.BOLD))
|
|
1360
|
+
for c in conflicts:
|
|
1361
|
+
print(f"\nPort: {c['port']}")
|
|
1362
|
+
print(f"- Docker container: {c['docker']['container_name']}")
|
|
1363
|
+
for lp in c["local"]:
|
|
1364
|
+
proc = lp.get("process") or {}
|
|
1365
|
+
print(f"- Local process: {proc.get('name') or 'Unknown'}")
|
|
1366
|
+
return EXIT_OK
|
|
1367
|
+
|
|
1368
|
+
return EXIT_INVALID_INPUT
|
|
1369
|
+
|
|
1370
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
1371
|
+
parser = argparse.ArgumentParser(
|
|
1372
|
+
description="kport - Cross-platform port inspector and killer (upgraded)",
|
|
1373
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1374
|
+
epilog="""
|
|
1375
|
+
Examples:
|
|
1376
|
+
kport.py -i 8080
|
|
1377
|
+
kport.py -im 3000 3001 3002
|
|
1378
|
+
kport.py -ir 3000-3010
|
|
1379
|
+
kport.py -ip node --exact
|
|
1380
|
+
kport.py -k 8080 --yes
|
|
1381
|
+
kport.py -kp node --dry-run --json
|
|
1382
|
+
"""
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
# Global options (PRODUCT.md)
|
|
1386
|
+
parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
|
|
1387
|
+
parser.add_argument("--dry-run", action="store_true", help="Show actions without executing")
|
|
1388
|
+
parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompts")
|
|
1389
|
+
parser.add_argument("--debug", action="store_true", help="Verbose internal logs")
|
|
1390
|
+
parser.add_argument("--config", type=str, default=None, help="Path to JSON config file (default: .kport.json or ~/.config/kport/config.json)")
|
|
1391
|
+
|
|
1392
|
+
# Legacy flags (backward compatible)
|
|
1393
|
+
parser.add_argument("-i", "--inspect", type=int, metavar="PORT", help="Inspect which process is using the specified port")
|
|
1394
|
+
parser.add_argument("-im", "--inspect-multiple", type=int, nargs="+", metavar="PORT", help="Inspect multiple ports")
|
|
1395
|
+
parser.add_argument("-ir", "--inspect-range", type=str, metavar="RANGE", help="Inspect port range (e.g., 3000-3010)")
|
|
1396
|
+
parser.add_argument("-ip", "--inspect-process", type=str, metavar="NAME", help="Inspect all processes matching the given name")
|
|
1397
|
+
parser.add_argument("-k", "--kill", type=int, metavar="PORT", help="Kill the process(es) using the specified port")
|
|
1398
|
+
parser.add_argument("-kp", "--kill-process", type=str, metavar="NAME", help="Kill all processes matching the given name")
|
|
1399
|
+
parser.add_argument("-ka", "--kill-all", type=int, nargs="+", metavar="PORT", help="Kill processes on multiple ports")
|
|
1400
|
+
parser.add_argument("-kr", "--kill-range", type=str, metavar="RANGE", help="Kill processes on port range (e.g., 3000-3010)")
|
|
1401
|
+
parser.add_argument("-l", "--list", action="store_true", help="List all listening ports and their processes")
|
|
1402
|
+
parser.add_argument("--exact", action="store_true", help="Use exact match for process name lookups")
|
|
1403
|
+
parser.add_argument("--force", action="store_true", help="Force kill immediately if needed (after graceful timeout)")
|
|
1404
|
+
parser.add_argument("--graceful-timeout", type=float, default=3.0, help="Seconds to wait for graceful termination before forcing (default 3.0)")
|
|
1405
|
+
parser.add_argument("-v", "--version", action="version", version="kport 3.0.0 (upgraded)")
|
|
1406
|
+
|
|
1407
|
+
# PRODUCT.md subcommands
|
|
1408
|
+
sub = parser.add_subparsers(dest="command")
|
|
1409
|
+
sp_inspect = sub.add_parser("inspect", help="Inspect a port (docker-aware)")
|
|
1410
|
+
sp_inspect.add_argument("port", type=int)
|
|
1411
|
+
sp_inspect.add_argument("--json", action="store_true")
|
|
1412
|
+
sp_inspect.add_argument("--debug", action="store_true")
|
|
1413
|
+
sp_inspect.add_argument("--config", type=str, default=None)
|
|
1414
|
+
|
|
1415
|
+
sp_explain = sub.add_parser("explain", help="Explain why a port is blocked")
|
|
1416
|
+
sp_explain.add_argument("port", type=int)
|
|
1417
|
+
sp_explain.add_argument("--json", action="store_true")
|
|
1418
|
+
sp_explain.add_argument("--debug", action="store_true")
|
|
1419
|
+
sp_explain.add_argument("--config", type=str, default=None)
|
|
1420
|
+
|
|
1421
|
+
sp_kill = sub.add_parser("kill", help="Safely free a port (docker-aware)")
|
|
1422
|
+
sp_kill.add_argument("port", type=int)
|
|
1423
|
+
sp_kill.add_argument("--docker-action", choices=["stop", "restart", "rm"], help="Action when port belongs to Docker")
|
|
1424
|
+
sp_kill.add_argument("--json", action="store_true")
|
|
1425
|
+
sp_kill.add_argument("--dry-run", action="store_true")
|
|
1426
|
+
sp_kill.add_argument("-y", "--yes", action="store_true")
|
|
1427
|
+
sp_kill.add_argument("--debug", action="store_true")
|
|
1428
|
+
sp_kill.add_argument("--force", action="store_true")
|
|
1429
|
+
sp_kill.add_argument("--graceful-timeout", type=float, default=3.0)
|
|
1430
|
+
sp_kill.add_argument("--config", type=str, default=None)
|
|
1431
|
+
|
|
1432
|
+
sp_kp = sub.add_parser("kill-process", help="Kill processes by name")
|
|
1433
|
+
sp_kp.add_argument("name", type=str)
|
|
1434
|
+
sp_kp.add_argument("--exact", action="store_true")
|
|
1435
|
+
sp_kp.add_argument("--json", action="store_true")
|
|
1436
|
+
sp_kp.add_argument("--dry-run", action="store_true")
|
|
1437
|
+
sp_kp.add_argument("-y", "--yes", action="store_true")
|
|
1438
|
+
sp_kp.add_argument("--debug", action="store_true")
|
|
1439
|
+
sp_kp.add_argument("--force", action="store_true")
|
|
1440
|
+
sp_kp.add_argument("--graceful-timeout", type=float, default=3.0)
|
|
1441
|
+
sp_kp.add_argument("--config", type=str, default=None)
|
|
1442
|
+
|
|
1443
|
+
sp_list = sub.add_parser("list", help="List active ports (local + docker)")
|
|
1444
|
+
sp_list.add_argument("--json", action="store_true")
|
|
1445
|
+
sp_list.add_argument("--debug", action="store_true")
|
|
1446
|
+
sp_list.add_argument("--config", type=str, default=None)
|
|
1447
|
+
|
|
1448
|
+
sp_docker = sub.add_parser("docker", help="List Docker-published ports")
|
|
1449
|
+
sp_docker.add_argument("--json", action="store_true")
|
|
1450
|
+
sp_docker.add_argument("--debug", action="store_true")
|
|
1451
|
+
sp_docker.add_argument("--config", type=str, default=None)
|
|
1452
|
+
|
|
1453
|
+
sp_conflicts = sub.add_parser("conflicts", help="Detect docker/local port conflicts")
|
|
1454
|
+
sp_conflicts.add_argument("--json", action="store_true")
|
|
1455
|
+
sp_conflicts.add_argument("--debug", action="store_true")
|
|
1456
|
+
sp_conflicts.add_argument("--config", type=str, default=None)
|
|
1457
|
+
|
|
1458
|
+
args = parser.parse_args(argv)
|
|
1459
|
+
|
|
1460
|
+
# Apply config defaults (if any)
|
|
1461
|
+
cfg = load_config(getattr(args, "config", None), debug=getattr(args, "debug", False))
|
|
1462
|
+
apply_config_defaults(args, cfg)
|
|
1463
|
+
|
|
1464
|
+
inspector = get_inspector()
|
|
1465
|
+
|
|
1466
|
+
# Convenience: if psutil not installed, show helpful hint once
|
|
1467
|
+
if not USING_PSUTIL:
|
|
1468
|
+
if not args.json:
|
|
1469
|
+
print(colorize("Notice: psutil not installed; falling back to system commands. Installing psutil improves reliability.", Colors.YELLOW))
|
|
1470
|
+
|
|
1471
|
+
try:
|
|
1472
|
+
# PRODUCT.md command mode
|
|
1473
|
+
if getattr(args, "command", None):
|
|
1474
|
+
return handle_product_command(args, inspector)
|
|
1475
|
+
|
|
1476
|
+
# No args => show help
|
|
1477
|
+
if not any([args.inspect, args.inspect_multiple, args.inspect_range, args.inspect_process, args.kill, args.list, args.kill_process, args.kill_all, args.kill_range]):
|
|
1478
|
+
parser.print_help()
|
|
1479
|
+
return EXIT_OK
|
|
1480
|
+
|
|
1481
|
+
# List all listening ports
|
|
1482
|
+
if args.list:
|
|
1483
|
+
bindings = inspector.list_listening()
|
|
1484
|
+
if args.json:
|
|
1485
|
+
print(jsonify_bindings(bindings))
|
|
1486
|
+
else:
|
|
1487
|
+
print(colorize("\n📋 Listening ports\n", Colors.CYAN + Colors.BOLD))
|
|
1488
|
+
print_table_listen(bindings)
|
|
1489
|
+
|
|
1490
|
+
# Inspect single port (legacy) - Docker-aware fallback
|
|
1491
|
+
if args.inspect:
|
|
1492
|
+
validate_port(args.inspect)
|
|
1493
|
+
local_bindings = [b for b in inspector.list_listening() if b.port == args.inspect]
|
|
1494
|
+
docker_hits = docker_mappings_for_host_port(args.inspect, debug=args.debug)
|
|
1495
|
+
pids = inspector.find_pids_on_port(args.inspect)
|
|
1496
|
+
if not pids:
|
|
1497
|
+
if docker_hits:
|
|
1498
|
+
m = docker_hits[0]
|
|
1499
|
+
if args.json:
|
|
1500
|
+
print(
|
|
1501
|
+
json.dumps(
|
|
1502
|
+
{
|
|
1503
|
+
"port": args.inspect,
|
|
1504
|
+
"type": "docker",
|
|
1505
|
+
"container": m.container_name,
|
|
1506
|
+
"image": m.image,
|
|
1507
|
+
"host_port": m.host_port,
|
|
1508
|
+
"container_port": m.container_port,
|
|
1509
|
+
"status": m.status,
|
|
1510
|
+
},
|
|
1511
|
+
indent=2,
|
|
1512
|
+
)
|
|
1513
|
+
)
|
|
1514
|
+
else:
|
|
1515
|
+
print(colorize(f"\n🐳 Port {args.inspect} is mapped to Docker container: {m.container_name}\n", Colors.GREEN + Colors.BOLD))
|
|
1516
|
+
print(f"Image: {m.image}")
|
|
1517
|
+
print(f"Host Port: {m.host_port} → Container Port: {m.container_port}/{m.proto}")
|
|
1518
|
+
print(f"Status: {m.status}")
|
|
1519
|
+
elif local_bindings:
|
|
1520
|
+
msg = "Port is in use, but the owning PID is not visible (try running with sudo/admin)."
|
|
1521
|
+
if args.json:
|
|
1522
|
+
print(json.dumps({"port": args.inspect, "type": "local-unknown", "message": msg, "bindings": [asdict(b) for b in local_bindings]}, indent=2))
|
|
1523
|
+
else:
|
|
1524
|
+
print(colorize("⚠ " + msg, Colors.YELLOW))
|
|
1525
|
+
else:
|
|
1526
|
+
msg = f"No processes found using port {args.inspect}"
|
|
1527
|
+
if args.json:
|
|
1528
|
+
print(json.dumps({"port": args.inspect, "pids": []}))
|
|
1529
|
+
else:
|
|
1530
|
+
print(colorize("❌ " + msg, Colors.RED))
|
|
1531
|
+
else:
|
|
1532
|
+
info_list = []
|
|
1533
|
+
for pid in pids:
|
|
1534
|
+
info = inspector.get_process_info(pid)
|
|
1535
|
+
info_list.append({"pid": pid, "process": asdict(info) if info else None})
|
|
1536
|
+
if args.json:
|
|
1537
|
+
out: Dict[str, Any] = {"port": args.inspect, "pids": info_list}
|
|
1538
|
+
if docker_hits:
|
|
1539
|
+
out["docker"] = [asdict(m) for m in docker_hits]
|
|
1540
|
+
print(json.dumps(out, indent=2))
|
|
1541
|
+
else:
|
|
1542
|
+
print(colorize(f"\n🔍 Port {args.inspect} is used by PID(s): {', '.join(map(str,pids))}\n", Colors.GREEN + Colors.BOLD))
|
|
1543
|
+
if docker_hits:
|
|
1544
|
+
m = docker_hits[0]
|
|
1545
|
+
print(colorize(f"🐳 Docker mapping: {m.container_name} ({m.image}) host {m.host_port} → {m.container_port}/{m.proto}", Colors.CYAN))
|
|
1546
|
+
for entry in info_list:
|
|
1547
|
+
pid = entry["pid"]
|
|
1548
|
+
proc = entry["process"]
|
|
1549
|
+
if proc:
|
|
1550
|
+
print(colorize(f"PID {pid}: {proc['name']} (user={proc.get('user')})", Colors.WHITE))
|
|
1551
|
+
if proc.get('cmdline'):
|
|
1552
|
+
print(f" cmd: {' '.join(proc['cmdline'])}")
|
|
1553
|
+
else:
|
|
1554
|
+
print(colorize(f"PID {pid}: info unavailable", Colors.YELLOW))
|
|
1555
|
+
|
|
1556
|
+
# Inspect multiple ports
|
|
1557
|
+
if args.inspect_multiple:
|
|
1558
|
+
ports = args.inspect_multiple
|
|
1559
|
+
results = []
|
|
1560
|
+
for port in ports:
|
|
1561
|
+
validate_port(port)
|
|
1562
|
+
pids = inspector.find_pids_on_port(port)
|
|
1563
|
+
for pid in pids:
|
|
1564
|
+
proc = inspector.get_process_info(pid)
|
|
1565
|
+
results.append({"port": port, "pid": pid, "process": asdict(proc) if proc else None})
|
|
1566
|
+
if args.json:
|
|
1567
|
+
print(json.dumps(results, indent=2))
|
|
1568
|
+
else:
|
|
1569
|
+
print(colorize(f"\n🔍 Inspecting {len(ports)} port(s)...\n", Colors.CYAN + Colors.BOLD))
|
|
1570
|
+
if not results:
|
|
1571
|
+
print(colorize("❌ No processes found on any of the specified ports", Colors.RED))
|
|
1572
|
+
else:
|
|
1573
|
+
print(colorize(f"{'Port':<8} {'PID':<8} {'Process':<30}", Colors.BOLD))
|
|
1574
|
+
print("─" * 60)
|
|
1575
|
+
for r in results:
|
|
1576
|
+
pname = r['process']['name'] if r['process'] else "-"
|
|
1577
|
+
print(f"{colorize(str(r['port']), Colors.CYAN):<8} {str(r['pid']):<8} {pname:<30}")
|
|
1578
|
+
print(colorize(f"\n✓ Found processes on {len(results)} items", Colors.GREEN))
|
|
1579
|
+
|
|
1580
|
+
# Inspect range
|
|
1581
|
+
if args.inspect_range:
|
|
1582
|
+
ports = parse_port_range(args.inspect_range)
|
|
1583
|
+
results = []
|
|
1584
|
+
for port in ports:
|
|
1585
|
+
pids = inspector.find_pids_on_port(port)
|
|
1586
|
+
for pid in pids:
|
|
1587
|
+
proc = inspector.get_process_info(pid)
|
|
1588
|
+
results.append({"port": port, "pid": pid, "process": asdict(proc) if proc else None})
|
|
1589
|
+
if args.json:
|
|
1590
|
+
print(json.dumps(results, indent=2))
|
|
1591
|
+
else:
|
|
1592
|
+
print(colorize(f"\n🔍 Inspecting port range {args.inspect_range} ({len(ports)} ports)...\n", Colors.CYAN + Colors.BOLD))
|
|
1593
|
+
if not results:
|
|
1594
|
+
print(colorize(f"❌ No processes found in port range {args.inspect_range}", Colors.RED))
|
|
1595
|
+
else:
|
|
1596
|
+
print(colorize(f"{'Port':<8} {'PID':<8} {'Process':<30}", Colors.BOLD))
|
|
1597
|
+
print("─" * 60)
|
|
1598
|
+
for r in results:
|
|
1599
|
+
pname = r['process']['name'] if r['process'] else "-"
|
|
1600
|
+
print(f"{colorize(str(r['port']), Colors.CYAN):<8} {str(r['pid']):<8} {pname:<30}")
|
|
1601
|
+
print(colorize(f"\n✓ Found processes on {len(results)} entries", Colors.GREEN))
|
|
1602
|
+
|
|
1603
|
+
# Inspect by process name
|
|
1604
|
+
if args.inspect_process:
|
|
1605
|
+
pname = args.inspect_process
|
|
1606
|
+
bindings = inspector.find_ports_by_process_name(pname, exact=args.exact)
|
|
1607
|
+
if args.json:
|
|
1608
|
+
print(jsonify_bindings(bindings))
|
|
1609
|
+
else:
|
|
1610
|
+
print(colorize(f"\n🔍 Inspecting processes matching '{pname}'\n", Colors.CYAN + Colors.BOLD))
|
|
1611
|
+
if not bindings:
|
|
1612
|
+
print(colorize(f"❌ No processes found matching '{pname}'", Colors.RED))
|
|
1613
|
+
else:
|
|
1614
|
+
pid_groups: Dict[int, List[PortBinding]] = {}
|
|
1615
|
+
for b in bindings:
|
|
1616
|
+
pid_groups.setdefault(b.pid or 0, []).append(b)
|
|
1617
|
+
print(colorize(f"{'PID':<8} {'Process':<25} {'Port':<8} {'State':<12}", Colors.BOLD))
|
|
1618
|
+
print("─" * 70)
|
|
1619
|
+
for pid, ports in pid_groups.items():
|
|
1620
|
+
proc_name = ports[0].process_name or "-"
|
|
1621
|
+
print(f"{colorize(str(pid), Colors.CYAN):<8} {proc_name:<25} {ports[0].port:<8} {ports[0].state or '-':<12}")
|
|
1622
|
+
for p in ports[1:]:
|
|
1623
|
+
print(f"{'':<8} {'':<25} {p.port:<8} {p.state or '-':<12}")
|
|
1624
|
+
print(colorize(f"\n✓ Total processes found: {len(pid_groups)}", Colors.GREEN))
|
|
1625
|
+
print(colorize(f"✓ Total connections: {len(bindings)}", Colors.GREEN))
|
|
1626
|
+
|
|
1627
|
+
# Kill by process name
|
|
1628
|
+
if args.kill_process:
|
|
1629
|
+
pname = args.kill_process
|
|
1630
|
+
pids = inspector.find_pids_by_name(pname, exact=args.exact)
|
|
1631
|
+
if not pids:
|
|
1632
|
+
if args.json:
|
|
1633
|
+
print(json.dumps({"name": pname, "pids": []}, indent=2))
|
|
1634
|
+
else:
|
|
1635
|
+
print(colorize(f"❌ No processes found matching '{pname}'", Colors.RED))
|
|
1636
|
+
else:
|
|
1637
|
+
if args.json:
|
|
1638
|
+
# In JSON mode, we won't prompt for confirmation; user should opt --yes if they want auto-approval in scripts.
|
|
1639
|
+
out = []
|
|
1640
|
+
for pid in pids:
|
|
1641
|
+
info = inspector.get_process_info(pid)
|
|
1642
|
+
out.append({"pid": pid, "process": asdict(info) if info else None})
|
|
1643
|
+
print(json.dumps({"name": pname, "pids": out}, indent=2))
|
|
1644
|
+
if not args.yes:
|
|
1645
|
+
print(colorize("Note: JSON output provided. Use --yes to actually perform kills.", Colors.YELLOW))
|
|
1646
|
+
else:
|
|
1647
|
+
# proceed to kill
|
|
1648
|
+
killed = []
|
|
1649
|
+
failed = []
|
|
1650
|
+
for pid in pids:
|
|
1651
|
+
ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
|
|
1652
|
+
if ok:
|
|
1653
|
+
killed.append({"pid": pid, "msg": msg})
|
|
1654
|
+
else:
|
|
1655
|
+
failed.append({"pid": pid, "msg": msg})
|
|
1656
|
+
print(json.dumps({"killed": killed, "failed": failed}, indent=2))
|
|
1657
|
+
else:
|
|
1658
|
+
print(colorize(f"Found {len(pids)} process(es) matching '{pname}':", Colors.YELLOW))
|
|
1659
|
+
for pid in pids:
|
|
1660
|
+
info = inspector.get_process_info(pid)
|
|
1661
|
+
display = f"PID {pid}: {info.name if info else 'Unknown'}"
|
|
1662
|
+
print(colorize(" " + display, Colors.WHITE))
|
|
1663
|
+
if not confirm_prompt(f"\nAre you sure you want to kill {len(pids)} process(es)?", assume_yes=args.yes):
|
|
1664
|
+
print(colorize("Operation cancelled.", Colors.YELLOW))
|
|
1665
|
+
else:
|
|
1666
|
+
killed_count = 0
|
|
1667
|
+
for pid in pids:
|
|
1668
|
+
ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
|
|
1669
|
+
if ok:
|
|
1670
|
+
killed_count += 1
|
|
1671
|
+
print(colorize(f"✓ Killed PID {pid} ({msg})", Colors.GREEN))
|
|
1672
|
+
else:
|
|
1673
|
+
print(colorize(f"✗ Failed to kill PID {pid} ({msg})", Colors.RED))
|
|
1674
|
+
print(colorize(f"\n✓ Successfully killed {killed_count}/{len(pids)} process(es)", Colors.GREEN + Colors.BOLD))
|
|
1675
|
+
|
|
1676
|
+
# Kill single port (legacy) - Docker-aware fallback
|
|
1677
|
+
if args.kill:
|
|
1678
|
+
validate_port(args.kill)
|
|
1679
|
+
local_bindings = [b for b in inspector.list_listening() if b.port == args.kill]
|
|
1680
|
+
docker_hits = docker_mappings_for_host_port(args.kill, debug=args.debug)
|
|
1681
|
+
pids = inspector.find_pids_on_port(args.kill)
|
|
1682
|
+
if not pids:
|
|
1683
|
+
if docker_hits:
|
|
1684
|
+
m = docker_hits[0]
|
|
1685
|
+
if args.json and not args.yes and not args.dry_run:
|
|
1686
|
+
print(
|
|
1687
|
+
json.dumps(
|
|
1688
|
+
{
|
|
1689
|
+
"port": args.kill,
|
|
1690
|
+
"type": "docker",
|
|
1691
|
+
"container": m.container_name,
|
|
1692
|
+
"container_id": m.container_id,
|
|
1693
|
+
"message": "Refusing to act without --yes in JSON mode",
|
|
1694
|
+
},
|
|
1695
|
+
indent=2,
|
|
1696
|
+
)
|
|
1697
|
+
)
|
|
1698
|
+
else:
|
|
1699
|
+
if not args.json:
|
|
1700
|
+
print(colorize(f"\n🐳 Port {args.kill} belongs to Docker container: {m.container_name}", Colors.YELLOW + Colors.BOLD))
|
|
1701
|
+
action = choose_docker_action(assume_yes=args.yes)
|
|
1702
|
+
else:
|
|
1703
|
+
action = "stop"
|
|
1704
|
+
if action:
|
|
1705
|
+
ok, msg = docker_action_on_container(m.container_id, action=action, dry_run=args.dry_run, debug=args.debug)
|
|
1706
|
+
if args.json:
|
|
1707
|
+
print(json.dumps({"port": args.kill, "type": "docker", "action": action, "ok": ok, "message": msg}, indent=2))
|
|
1708
|
+
else:
|
|
1709
|
+
print(colorize(("✓ " if ok else "✗ ") + msg, Colors.GREEN if ok else Colors.RED))
|
|
1710
|
+
elif local_bindings:
|
|
1711
|
+
msg = "Port is in use but PID is not visible; cannot kill safely. Try sudo/admin."
|
|
1712
|
+
if args.json:
|
|
1713
|
+
print(json.dumps({"port": args.kill, "ok": False, "message": msg, "bindings": [asdict(b) for b in local_bindings]}, indent=2))
|
|
1714
|
+
else:
|
|
1715
|
+
print(colorize(msg, Colors.RED))
|
|
1716
|
+
else:
|
|
1717
|
+
if args.json:
|
|
1718
|
+
print(json.dumps({"port": args.kill, "killed": [], "failed": []}, indent=2))
|
|
1719
|
+
else:
|
|
1720
|
+
print(colorize(f"❌ No process found using port {args.kill}", Colors.RED))
|
|
1721
|
+
else:
|
|
1722
|
+
if args.json:
|
|
1723
|
+
out_killed = []
|
|
1724
|
+
out_failed = []
|
|
1725
|
+
for pid in pids:
|
|
1726
|
+
ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
|
|
1727
|
+
if ok:
|
|
1728
|
+
out_killed.append({"pid": pid, "msg": msg})
|
|
1729
|
+
else:
|
|
1730
|
+
out_failed.append({"pid": pid, "msg": msg})
|
|
1731
|
+
out: Dict[str, Any] = {"port": args.kill, "killed": out_killed, "failed": out_failed}
|
|
1732
|
+
if docker_hits:
|
|
1733
|
+
out["docker"] = [asdict(m) for m in docker_hits]
|
|
1734
|
+
print(json.dumps(out, indent=2))
|
|
1735
|
+
else:
|
|
1736
|
+
print(colorize(f"Found PID(s) {', '.join(map(str,pids))} using port {args.kill}", Colors.YELLOW))
|
|
1737
|
+
if docker_hits:
|
|
1738
|
+
m = docker_hits[0]
|
|
1739
|
+
print(colorize(f"🐳 Docker mapping: {m.container_name} ({m.image}) host {m.host_port} → {m.container_port}/{m.proto}", Colors.CYAN))
|
|
1740
|
+
for pid in pids:
|
|
1741
|
+
info = inspector.get_process_info(pid)
|
|
1742
|
+
if info:
|
|
1743
|
+
print(colorize(f"\nProcess to be terminated: PID {pid} - {info.name}", Colors.YELLOW))
|
|
1744
|
+
if info.cmdline:
|
|
1745
|
+
print(" cmd:", ' '.join(info.cmdline))
|
|
1746
|
+
if not confirm_prompt("\nAre you sure you want to kill this process(es)?", assume_yes=args.yes):
|
|
1747
|
+
print(colorize("Operation cancelled.", Colors.YELLOW))
|
|
1748
|
+
else:
|
|
1749
|
+
killed_count = 0
|
|
1750
|
+
for pid in pids:
|
|
1751
|
+
ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
|
|
1752
|
+
if ok:
|
|
1753
|
+
killed_count += 1
|
|
1754
|
+
print(colorize(f"✓ Killed PID {pid} ({msg})", Colors.GREEN))
|
|
1755
|
+
else:
|
|
1756
|
+
print(colorize(f"✗ Failed to kill PID {pid} ({msg})", Colors.RED))
|
|
1757
|
+
print(colorize(f"\n✓ Successfully killed {killed_count}/{len(pids)} process(es)", Colors.GREEN + Colors.BOLD))
|
|
1758
|
+
|
|
1759
|
+
# Kill multiple ports list
|
|
1760
|
+
if args.kill_all:
|
|
1761
|
+
for port in args.kill_all:
|
|
1762
|
+
validate_port(port)
|
|
1763
|
+
port_pid_map: Dict[int, List[int]] = {}
|
|
1764
|
+
for port in args.kill_all:
|
|
1765
|
+
pids = inspector.find_pids_on_port(port)
|
|
1766
|
+
if pids:
|
|
1767
|
+
port_pid_map[port] = pids
|
|
1768
|
+
if not port_pid_map:
|
|
1769
|
+
print(colorize("❌ No processes found on any of the specified ports", Colors.RED))
|
|
1770
|
+
else:
|
|
1771
|
+
print(colorize("Found processes on the following ports:", Colors.YELLOW))
|
|
1772
|
+
for port, pids in port_pid_map.items():
|
|
1773
|
+
names = [inspector.get_process_info(pid).name if inspector.get_process_info(pid) else "?" for pid in pids]
|
|
1774
|
+
print(colorize(f" Port {port}: PIDs {', '.join(map(str,pids))} ({', '.join(names)})", Colors.WHITE))
|
|
1775
|
+
if not confirm_prompt(f"\nAre you sure you want to kill {sum(len(ps) for ps in port_pid_map.values())} process(es)?", assume_yes=args.yes):
|
|
1776
|
+
print(colorize("Operation cancelled.", Colors.YELLOW))
|
|
1777
|
+
else:
|
|
1778
|
+
killed_count = 0
|
|
1779
|
+
total = sum(len(ps) for ps in port_pid_map.values())
|
|
1780
|
+
for port, pids in port_pid_map.items():
|
|
1781
|
+
for pid in pids:
|
|
1782
|
+
ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
|
|
1783
|
+
if ok:
|
|
1784
|
+
killed_count += 1
|
|
1785
|
+
print(colorize(f"✓ Killed PID {pid} (port {port})", Colors.GREEN))
|
|
1786
|
+
else:
|
|
1787
|
+
print(colorize(f"✗ Failed to kill PID {pid} (port {port}): {msg}", Colors.RED))
|
|
1788
|
+
print(colorize(f"\n✓ Successfully killed {killed_count}/{total} process(es)", Colors.GREEN + Colors.BOLD))
|
|
1789
|
+
|
|
1790
|
+
# Kill range
|
|
1791
|
+
if args.kill_range:
|
|
1792
|
+
ports = parse_port_range(args.kill_range)
|
|
1793
|
+
port_pid_map = {}
|
|
1794
|
+
for port in ports:
|
|
1795
|
+
pids = inspector.find_pids_on_port(port)
|
|
1796
|
+
if pids:
|
|
1797
|
+
port_pid_map[port] = pids
|
|
1798
|
+
if not port_pid_map:
|
|
1799
|
+
print(colorize(f"❌ No processes found in port range {args.kill_range}", Colors.RED))
|
|
1800
|
+
else:
|
|
1801
|
+
print(colorize(f"Found processes on {len(port_pid_map)} port(s) in range:", Colors.YELLOW))
|
|
1802
|
+
for port, pids in port_pid_map.items():
|
|
1803
|
+
print(colorize(f" Port {port}: PIDs {', '.join(map(str,pids))}", Colors.WHITE))
|
|
1804
|
+
if not confirm_prompt(f"\nAre you sure you want to kill {sum(len(ps) for ps in port_pid_map.values())} process(es)?", assume_yes=args.yes):
|
|
1805
|
+
print(colorize("Operation cancelled.", Colors.YELLOW))
|
|
1806
|
+
else:
|
|
1807
|
+
killed_count = 0
|
|
1808
|
+
total = sum(len(ps) for ps in port_pid_map.values())
|
|
1809
|
+
for port, pids in port_pid_map.items():
|
|
1810
|
+
for pid in pids:
|
|
1811
|
+
ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
|
|
1812
|
+
if ok:
|
|
1813
|
+
killed_count += 1
|
|
1814
|
+
print(colorize(f"✓ Killed PID {pid} (port {port})", Colors.GREEN))
|
|
1815
|
+
else:
|
|
1816
|
+
print(colorize(f"✗ Failed to kill PID {pid} (port {port}): {msg}", Colors.RED))
|
|
1817
|
+
print(colorize(f"\n✓ Successfully killed {killed_count}/{total} process(es)", Colors.GREEN + Colors.BOLD))
|
|
1818
|
+
|
|
1819
|
+
except PermissionError:
|
|
1820
|
+
print(colorize("Permission denied. Try running with elevated privileges (sudo / admin).", Colors.RED), file=sys.stderr)
|
|
1821
|
+
return EXIT_PERMISSION
|
|
1822
|
+
except KeyboardInterrupt:
|
|
1823
|
+
print(colorize("\nOperation cancelled by user.", Colors.YELLOW))
|
|
1824
|
+
return EXIT_GENERAL_ERROR
|
|
1825
|
+
except Exception as e:
|
|
1826
|
+
print(colorize(f"Unexpected error: {e}", Colors.RED), file=sys.stderr)
|
|
1827
|
+
return EXIT_GENERAL_ERROR
|
|
1828
|
+
|
|
1829
|
+
return EXIT_OK
|
|
1830
|
+
|
|
1831
|
+
if __name__ == "__main__":
|
|
1832
|
+
rc = main()
|
|
1833
|
+
sys.exit(rc)
|