vzrecon 3.0.0__tar.gz
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.
- vzrecon-3.0.0/LICENSE +15 -0
- vzrecon-3.0.0/PKG-INFO +74 -0
- vzrecon-3.0.0/README.md +43 -0
- vzrecon-3.0.0/pyproject.toml +50 -0
- vzrecon-3.0.0/setup.cfg +4 -0
- vzrecon-3.0.0/tests/test_config.py +40 -0
- vzrecon-3.0.0/tests/test_metrics.py +29 -0
- vzrecon-3.0.0/tests/test_protocols.py +50 -0
- vzrecon-3.0.0/vzrecon/__init__.py +4 -0
- vzrecon-3.0.0/vzrecon/__main__.py +3 -0
- vzrecon-3.0.0/vzrecon/brand.py +42 -0
- vzrecon-3.0.0/vzrecon/cli.py +131 -0
- vzrecon-3.0.0/vzrecon/config.py +42 -0
- vzrecon-3.0.0/vzrecon/engine.py +66 -0
- vzrecon-3.0.0/vzrecon/metrics.py +54 -0
- vzrecon-3.0.0/vzrecon/platform.py +23 -0
- vzrecon-3.0.0/vzrecon/protocols.py +100 -0
- vzrecon-3.0.0/vzrecon/py.typed +0 -0
- vzrecon-3.0.0/vzrecon/selftest.py +54 -0
- vzrecon-3.0.0/vzrecon/tui.py +206 -0
- vzrecon-3.0.0/vzrecon/writers.py +14 -0
- vzrecon-3.0.0/vzrecon.egg-info/PKG-INFO +74 -0
- vzrecon-3.0.0/vzrecon.egg-info/SOURCES.txt +25 -0
- vzrecon-3.0.0/vzrecon.egg-info/dependency_links.txt +1 -0
- vzrecon-3.0.0/vzrecon.egg-info/entry_points.txt +2 -0
- vzrecon-3.0.0/vzrecon.egg-info/requires.txt +4 -0
- vzrecon-3.0.0/vzrecon.egg-info/top_level.txt +1 -0
vzrecon-3.0.0/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VZLOI GROUP
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
vzrecon-3.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vzrecon
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Protocol-level validator for HTTP/SOCKS4/SOCKS5 proxies
|
|
5
|
+
Author: VZLOI GROUP
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/vzloi/vzrecon
|
|
8
|
+
Project-URL: Source, https://github.com/vzloi/vzrecon
|
|
9
|
+
Project-URL: Issues, https://github.com/vzloi/vzrecon/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/vzloi/vzrecon/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: proxy,socks,socks5,socks4,validator,recon,async
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: Operating System :: POSIX
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Internet
|
|
22
|
+
Classifier: Topic :: System :: Networking
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# vzrecon
|
|
33
|
+
|
|
34
|
+
Protocol-level validator for HTTP, SOCKS4 and SOCKS5 proxies.
|
|
35
|
+
Zero dependencies. Termux-native.
|
|
36
|
+
|
|
37
|
+
Signed: VZLOI GROUP // ops
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
pip install -e .
|
|
42
|
+
vzrecon --self-test
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
vzrecon # scan cwd for *_proxies.txt
|
|
47
|
+
vzrecon -d ./lists -o ./out # explicit paths
|
|
48
|
+
vzrecon --preset stealth # 200 workers
|
|
49
|
+
vzrecon --preset turbo # 2400 workers
|
|
50
|
+
vzrecon --only http,socks5
|
|
51
|
+
vzrecon --plain
|
|
52
|
+
|
|
53
|
+
## Protocols
|
|
54
|
+
|
|
55
|
+
- HTTP RFC 7231 CONNECT + forward GET fallback
|
|
56
|
+
- SOCKS5 RFC 1928, no-auth greeting
|
|
57
|
+
- SOCKS4 original spec, IPv4 targets only
|
|
58
|
+
|
|
59
|
+
## Output
|
|
60
|
+
|
|
61
|
+
- `<proto>_alive.txt` — endpoints, one per line
|
|
62
|
+
- `<proto>_alive.json` — signed_by, protocol, count, proxies[]
|
|
63
|
+
- `<proto>_alive.csv` — endpoint, latency_ms
|
|
64
|
+
|
|
65
|
+
## Exit codes
|
|
66
|
+
|
|
67
|
+
- 0 scan completed
|
|
68
|
+
- 1 self-test failed
|
|
69
|
+
- 2 config error
|
|
70
|
+
- 3 no source files found
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT. Copyright (c) 2026 VZLOI GROUP.
|
vzrecon-3.0.0/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# vzrecon
|
|
2
|
+
|
|
3
|
+
Protocol-level validator for HTTP, SOCKS4 and SOCKS5 proxies.
|
|
4
|
+
Zero dependencies. Termux-native.
|
|
5
|
+
|
|
6
|
+
Signed: VZLOI GROUP // ops
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
pip install -e .
|
|
11
|
+
vzrecon --self-test
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
vzrecon # scan cwd for *_proxies.txt
|
|
16
|
+
vzrecon -d ./lists -o ./out # explicit paths
|
|
17
|
+
vzrecon --preset stealth # 200 workers
|
|
18
|
+
vzrecon --preset turbo # 2400 workers
|
|
19
|
+
vzrecon --only http,socks5
|
|
20
|
+
vzrecon --plain
|
|
21
|
+
|
|
22
|
+
## Protocols
|
|
23
|
+
|
|
24
|
+
- HTTP RFC 7231 CONNECT + forward GET fallback
|
|
25
|
+
- SOCKS5 RFC 1928, no-auth greeting
|
|
26
|
+
- SOCKS4 original spec, IPv4 targets only
|
|
27
|
+
|
|
28
|
+
## Output
|
|
29
|
+
|
|
30
|
+
- `<proto>_alive.txt` — endpoints, one per line
|
|
31
|
+
- `<proto>_alive.json` — signed_by, protocol, count, proxies[]
|
|
32
|
+
- `<proto>_alive.csv` — endpoint, latency_ms
|
|
33
|
+
|
|
34
|
+
## Exit codes
|
|
35
|
+
|
|
36
|
+
- 0 scan completed
|
|
37
|
+
- 1 self-test failed
|
|
38
|
+
- 2 config error
|
|
39
|
+
- 3 no source files found
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT. Copyright (c) 2026 VZLOI GROUP.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vzrecon"
|
|
7
|
+
version = "3.0.0"
|
|
8
|
+
description = "Protocol-level validator for HTTP/SOCKS4/SOCKS5 proxies"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{name = "VZLOI GROUP"}]
|
|
13
|
+
keywords = ["proxy","socks","socks5","socks4","validator","recon","async"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: System Administrators",
|
|
19
|
+
"Operating System :: POSIX",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Internet",
|
|
25
|
+
"Topic :: System :: Networking",
|
|
26
|
+
"Topic :: Utilities",
|
|
27
|
+
]
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/vzloi/vzrecon"
|
|
35
|
+
Source = "https://github.com/vzloi/vzrecon"
|
|
36
|
+
Issues = "https://github.com/vzloi/vzrecon/issues"
|
|
37
|
+
Changelog = "https://github.com/vzloi/vzrecon/blob/main/CHANGELOG.md"
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
vzrecon = "vzrecon.cli:main"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
include = ["vzrecon*"]
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.package-data]
|
|
46
|
+
vzrecon = ["py.typed"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
asyncio_mode = "auto"
|
|
50
|
+
testpaths = ["tests"]
|
vzrecon-3.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from vzrecon.config import Config, PRESETS
|
|
6
|
+
|
|
7
|
+
def _write(tmp_path, body):
|
|
8
|
+
p = Path(tmp_path) / "c.toml"
|
|
9
|
+
p.write_text(body)
|
|
10
|
+
return p
|
|
11
|
+
|
|
12
|
+
def test_defaults():
|
|
13
|
+
Config().validate()
|
|
14
|
+
|
|
15
|
+
def test_toml(tmp_path):
|
|
16
|
+
p = _write(tmp_path, 'workers = 500\ntarget_port = 8080\n')
|
|
17
|
+
c = Config.from_toml(p)
|
|
18
|
+
assert c.workers == 500
|
|
19
|
+
assert c.target_port == 8080
|
|
20
|
+
|
|
21
|
+
def test_unknown(tmp_path):
|
|
22
|
+
p = _write(tmp_path, 'nope = 1\n')
|
|
23
|
+
with pytest.raises(ValueError):
|
|
24
|
+
Config.from_toml(p)
|
|
25
|
+
|
|
26
|
+
def test_bad_port():
|
|
27
|
+
with pytest.raises(ValueError):
|
|
28
|
+
Config(target_port=0).validate()
|
|
29
|
+
|
|
30
|
+
def test_bad_format():
|
|
31
|
+
with pytest.raises(ValueError):
|
|
32
|
+
Config(format="xml").validate()
|
|
33
|
+
|
|
34
|
+
def test_preset():
|
|
35
|
+
c = Config().apply_preset("stealth")
|
|
36
|
+
assert c.workers == PRESETS["stealth"]["workers"]
|
|
37
|
+
|
|
38
|
+
def test_bad_preset():
|
|
39
|
+
with pytest.raises(ValueError):
|
|
40
|
+
Config().apply_preset("nope")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from vzrecon.metrics import Meter, fmt_dur, fmt_int
|
|
2
|
+
|
|
3
|
+
def test_tick():
|
|
4
|
+
m = Meter()
|
|
5
|
+
m.tick(ok=True, latency_ms=10)
|
|
6
|
+
m.tick(ok=False)
|
|
7
|
+
m.tick(ok=True, latency_ms=20)
|
|
8
|
+
assert m.done == 3
|
|
9
|
+
assert m.ok == 2
|
|
10
|
+
assert m.fail == 1
|
|
11
|
+
|
|
12
|
+
def test_p95():
|
|
13
|
+
m = Meter()
|
|
14
|
+
for v in range(100):
|
|
15
|
+
m.tick(ok=True, latency_ms=float(v))
|
|
16
|
+
assert m.percentile(0.5) == 50
|
|
17
|
+
assert m.percentile(0.95) == 95
|
|
18
|
+
|
|
19
|
+
def test_progress():
|
|
20
|
+
m = Meter()
|
|
21
|
+
m.total = 10
|
|
22
|
+
for _ in range(4):
|
|
23
|
+
m.tick(ok=True)
|
|
24
|
+
assert m.progress == 0.4
|
|
25
|
+
|
|
26
|
+
def test_fmt():
|
|
27
|
+
assert fmt_int(1234567) == "1 234 567"
|
|
28
|
+
assert fmt_dur(None) == "--:--"
|
|
29
|
+
assert fmt_dur(65) == "01:05"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from vzrecon.protocols import (
|
|
7
|
+
http_connect,
|
|
8
|
+
socks4_connect,
|
|
9
|
+
socks5_connect,
|
|
10
|
+
)
|
|
11
|
+
from vzrecon.selftest import _mh, _m4, _m5
|
|
12
|
+
|
|
13
|
+
async def _serve(h):
|
|
14
|
+
srv = await asyncio.start_server(h, "127.0.0.1", 0)
|
|
15
|
+
return srv, srv.sockets[0].getsockname()[1]
|
|
16
|
+
|
|
17
|
+
async def _run(handler, validator):
|
|
18
|
+
srv, port = await _serve(handler)
|
|
19
|
+
try:
|
|
20
|
+
return await validator(
|
|
21
|
+
"127.0.0.1", port, "example.com", 443,
|
|
22
|
+
tcp_timeout=2.0, hs_timeout=2.0,
|
|
23
|
+
)
|
|
24
|
+
finally:
|
|
25
|
+
srv.close()
|
|
26
|
+
with contextlib.suppress(Exception):
|
|
27
|
+
await srv.wait_closed()
|
|
28
|
+
|
|
29
|
+
@pytest.mark.asyncio
|
|
30
|
+
async def test_http():
|
|
31
|
+
lat = await _run(_mh, http_connect)
|
|
32
|
+
assert lat is not None and lat > 0
|
|
33
|
+
|
|
34
|
+
@pytest.mark.asyncio
|
|
35
|
+
async def test_socks5():
|
|
36
|
+
lat = await _run(_m5, socks5_connect)
|
|
37
|
+
assert lat is not None and lat > 0
|
|
38
|
+
|
|
39
|
+
@pytest.mark.asyncio
|
|
40
|
+
async def test_socks4():
|
|
41
|
+
lat = await _run(_m4, socks4_connect)
|
|
42
|
+
assert lat is not None and lat > 0
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_refused():
|
|
46
|
+
lat = await http_connect(
|
|
47
|
+
"127.0.0.1", 1, "example.com", 443,
|
|
48
|
+
tcp_timeout=0.5, hs_timeout=0.5,
|
|
49
|
+
)
|
|
50
|
+
assert lat is None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
BANNER = (
|
|
2
|
+
" ██╗ ██╗███████╗██╗ ██████╗ ██╗",
|
|
3
|
+
" ██║ ██║╚══███╔╝██║ ██╔═══██╗██║",
|
|
4
|
+
" ██║ ██║ ███╔╝ ██║ ██║ ██║██║",
|
|
5
|
+
" ╚██╗ ██╔╝ ███╔╝ ██║ ██║ ██║██║",
|
|
6
|
+
" ╚████╔╝ ███████╗███████╗╚██████╔╝██║",
|
|
7
|
+
" ╚═══╝ ╚══════╝╚══════╝ ╚═════╝ ╚═╝",
|
|
8
|
+
)
|
|
9
|
+
CREW = "VZLOI GROUP"
|
|
10
|
+
SUFFIX = "ops"
|
|
11
|
+
TOOL = "vzrecon"
|
|
12
|
+
CODENAME = "IRON VEIL"
|
|
13
|
+
TAGLINE = "recon division"
|
|
14
|
+
|
|
15
|
+
class SGR:
|
|
16
|
+
R = "\x1b[0m"
|
|
17
|
+
B = "\x1b[1m"
|
|
18
|
+
D = "\x1b[2m"
|
|
19
|
+
I = "\x1b[3m"
|
|
20
|
+
U = "\x1b[4m"
|
|
21
|
+
|
|
22
|
+
BLK = "\x1b[38;5;232m"
|
|
23
|
+
IRON = "\x1b[38;5;240m"
|
|
24
|
+
SLATE = "\x1b[38;5;244m"
|
|
25
|
+
ASH = "\x1b[38;5;250m"
|
|
26
|
+
IVORY = "\x1b[38;5;255m"
|
|
27
|
+
|
|
28
|
+
BLOOD = "\x1b[38;5;52m"
|
|
29
|
+
CRIM = "\x1b[38;5;160m"
|
|
30
|
+
FLARE = "\x1b[38;5;203m"
|
|
31
|
+
GOLD = "\x1b[38;5;220m"
|
|
32
|
+
AMBER = "\x1b[38;5;214m"
|
|
33
|
+
|
|
34
|
+
CYAN = "\x1b[38;5;66m"
|
|
35
|
+
ICE = "\x1b[38;5;117m"
|
|
36
|
+
VERD = "\x1b[38;5;77m"
|
|
37
|
+
GREEN = "\x1b[38;5;114m"
|
|
38
|
+
|
|
39
|
+
# aliases for legacy call sites
|
|
40
|
+
DIM = IRON
|
|
41
|
+
STEEL = SLATE
|
|
42
|
+
GRN = VERD
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import argparse, asyncio, contextlib, secrets, sys, time
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from . import __codename__, __version__
|
|
4
|
+
from .brand import BANNER, CREW, SGR, SUFFIX, TOOL
|
|
5
|
+
from .config import PRESETS, Config
|
|
6
|
+
from .engine import Scan, discover
|
|
7
|
+
from .platform import notify, wake_lock, wake_unlock
|
|
8
|
+
from .tui import TUI
|
|
9
|
+
from .writers import emit_csv, emit_json, emit_txt
|
|
10
|
+
def _parse(a):
|
|
11
|
+
p=argparse.ArgumentParser(prog=TOOL,
|
|
12
|
+
description=f"proxy validator // {CREW} {SUFFIX}")
|
|
13
|
+
p.add_argument("--version",action="version",
|
|
14
|
+
version=f"{TOOL} {__version__} ({__codename__})")
|
|
15
|
+
p.add_argument("-c","--config",type=Path)
|
|
16
|
+
p.add_argument("-d","--dir",type=Path,default=Path.cwd())
|
|
17
|
+
p.add_argument("-o","--output-dir",type=Path,default=Path("."))
|
|
18
|
+
p.add_argument("-t","--target")
|
|
19
|
+
p.add_argument("-w","--workers",type=int)
|
|
20
|
+
p.add_argument("--format",choices=("txt","json","csv"))
|
|
21
|
+
p.add_argument("--only")
|
|
22
|
+
p.add_argument("--preset",choices=tuple(PRESETS.keys()))
|
|
23
|
+
p.add_argument("--list-presets",action="store_true")
|
|
24
|
+
p.add_argument("--self-test",action="store_true")
|
|
25
|
+
p.add_argument("--plain",action="store_true")
|
|
26
|
+
p.add_argument("--no-notify",action="store_true")
|
|
27
|
+
p.add_argument("--verify",action="store_true",
|
|
28
|
+
help="send a real request through the tunnel")
|
|
29
|
+
return p.parse_args(a)
|
|
30
|
+
def _merge(a):
|
|
31
|
+
c=Config.from_toml(a.config) if a.config else Config()
|
|
32
|
+
if a.preset: c=c.apply_preset(a.preset)
|
|
33
|
+
c.source_dir=str(a.dir); c.output_dir=str(a.output_dir)
|
|
34
|
+
if a.workers is not None: c.workers=a.workers
|
|
35
|
+
if a.format: c.format=a.format
|
|
36
|
+
if a.target:
|
|
37
|
+
h,_,pt=a.target.partition(":")
|
|
38
|
+
c.target_host=h; c.target_port=int(pt) if pt else 443
|
|
39
|
+
if a.only:
|
|
40
|
+
c.protocols=tuple(s.strip() for s in a.only.split(",") if s.strip())
|
|
41
|
+
if a.no_notify: c.notify=False
|
|
42
|
+
if a.verify: c.verify=True
|
|
43
|
+
c.validate(); return c
|
|
44
|
+
async def _dispatch(scans,cfg,plain):
|
|
45
|
+
if plain:
|
|
46
|
+
await asyncio.gather(*(s.run() for s in scans)); return
|
|
47
|
+
sid=secrets.token_hex(4).upper()
|
|
48
|
+
tui=TUI(scans,f"{cfg.target_host}:{cfg.target_port}",
|
|
49
|
+
cfg.workers,sid,"balanced")
|
|
50
|
+
tui.attach(); tui.enter()
|
|
51
|
+
ts=[asyncio.create_task(s.run()) for s in scans]
|
|
52
|
+
tick=1.0/cfg.render_hz
|
|
53
|
+
try:
|
|
54
|
+
while not all(t.done() for t in ts):
|
|
55
|
+
for s in scans: s.meter.sample()
|
|
56
|
+
tui.draw(); await asyncio.sleep(tick)
|
|
57
|
+
tui.draw()
|
|
58
|
+
finally:
|
|
59
|
+
for t in ts: t.cancel()
|
|
60
|
+
with contextlib.suppress(Exception):
|
|
61
|
+
await asyncio.gather(*ts,return_exceptions=True)
|
|
62
|
+
tui.leave()
|
|
63
|
+
def _write(scans,cfg,out):
|
|
64
|
+
out.mkdir(parents=True,exist_ok=True)
|
|
65
|
+
for s in scans:
|
|
66
|
+
stem=out/f"{s.kind}_alive"
|
|
67
|
+
if cfg.format=="txt": emit_txt(stem.with_suffix(".txt"),s.hits)
|
|
68
|
+
elif cfg.format=="json": emit_json(stem.with_suffix(".json"),s.kind,s.hits)
|
|
69
|
+
else: emit_csv(stem.with_suffix(".csv"),s.hits)
|
|
70
|
+
def _banner():
|
|
71
|
+
return "\n".join(f" {SGR.CRIM}{l}{SGR.R}" for l in BANNER)
|
|
72
|
+
def _presets():
|
|
73
|
+
print(f"\n {SGR.CRIM}{SGR.B}{CREW} // {SUFFIX}{SGR.R} · presets")
|
|
74
|
+
print(f" {SGR.DIM}{'─'*46}{SGR.R}")
|
|
75
|
+
for n,v in PRESETS.items():
|
|
76
|
+
print(f" {SGR.GOLD}{n:<10}{SGR.R} {SGR.STEEL}"
|
|
77
|
+
f"workers={v['workers']:<5} tcp={v['tcp_timeout']}s "
|
|
78
|
+
f"hs={v['hs_timeout']}s hz={v['render_hz']}{SGR.R}")
|
|
79
|
+
print(); return 0
|
|
80
|
+
def main(argv=None):
|
|
81
|
+
a=_parse(argv)
|
|
82
|
+
if a.list_presets: return _presets()
|
|
83
|
+
if a.self_test:
|
|
84
|
+
from .selftest import run
|
|
85
|
+
print(f"\n {SGR.CRIM}{SGR.B}{CREW} // {SUFFIX}{SGR.R} "
|
|
86
|
+
f"· protocol self-test")
|
|
87
|
+
print(f" {SGR.DIM}{'─'*46}{SGR.R}")
|
|
88
|
+
rc=asyncio.run(run()); print(); return rc
|
|
89
|
+
try: cfg=_merge(a)
|
|
90
|
+
except (ValueError,FileNotFoundError) as e:
|
|
91
|
+
print(f"{SGR.CRIM}config:{SGR.R} {e}",file=sys.stderr)
|
|
92
|
+
return 2
|
|
93
|
+
src=discover(cfg)
|
|
94
|
+
if not src:
|
|
95
|
+
print(f"{SGR.CRIM}no sources{SGR.R} in {cfg.source_dir}",
|
|
96
|
+
file=sys.stderr)
|
|
97
|
+
return 3
|
|
98
|
+
print(_banner())
|
|
99
|
+
print(f" {SGR.STEEL}protocols:{SGR.R} {', '.join(k for k,_ in src)}")
|
|
100
|
+
print(f" {SGR.STEEL}sources: {SGR.R} {len(src)} file(s)")
|
|
101
|
+
print(f" {SGR.STEEL}target: {SGR.R} {cfg.target_host}:{cfg.target_port}")
|
|
102
|
+
print(f" {SGR.STEEL}workers: {SGR.R} {cfg.workers}\n")
|
|
103
|
+
if cfg.wake_lock: wake_lock()
|
|
104
|
+
scans=[]
|
|
105
|
+
Path(cfg.output_dir).mkdir(parents=True,exist_ok=True)
|
|
106
|
+
for k,s in src:
|
|
107
|
+
scans.append(Scan(k,s,Path(cfg.output_dir)/f"{k}_alive.txt",cfg))
|
|
108
|
+
t0=time.monotonic()
|
|
109
|
+
try:
|
|
110
|
+
with contextlib.suppress(KeyboardInterrupt):
|
|
111
|
+
asyncio.run(_dispatch(scans,cfg,a.plain))
|
|
112
|
+
finally:
|
|
113
|
+
if cfg.wake_lock: wake_unlock()
|
|
114
|
+
dt=time.monotonic()-t0
|
|
115
|
+
_write(scans,cfg,Path(cfg.output_dir))
|
|
116
|
+
print(f"\n {SGR.CRIM}{SGR.B}{CREW} // {SUFFIX}{SGR.R} "
|
|
117
|
+
f"· scan complete")
|
|
118
|
+
print(f" {SGR.DIM}{'─'*46}{SGR.R}")
|
|
119
|
+
print(f" {SGR.STEEL}duration:{SGR.R} {dt:.1f}s")
|
|
120
|
+
total=0
|
|
121
|
+
for s in scans:
|
|
122
|
+
m=s.meter
|
|
123
|
+
pct=(m.ok/m.done*100) if m.done else 0
|
|
124
|
+
total+=m.ok
|
|
125
|
+
print(f" {s.kind:<7} {m.ok:>6} / {m.done:<6} ({pct:5.1f}%)")
|
|
126
|
+
print(f" {SGR.DIM}{'─'*46}{SGR.R}")
|
|
127
|
+
print(f" total {SGR.GRN}{total}{SGR.R} alive → {cfg.output_dir}\n")
|
|
128
|
+
if cfg.notify:
|
|
129
|
+
notify(f"{TOOL} · scan complete",
|
|
130
|
+
f"{total} alive · {sum(s.meter.done for s in scans)} checked · {dt:.0f}s")
|
|
131
|
+
return 0
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import tomllib
|
|
3
|
+
from dataclasses import dataclass,fields,replace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
PRESETS={
|
|
6
|
+
"stealth": {"workers":200,"tcp_timeout":4.0,"hs_timeout":8.0,"render_hz":2.0},
|
|
7
|
+
"balanced":{"workers":1200,"tcp_timeout":3.0,"hs_timeout":6.0,"render_hz":3.0},
|
|
8
|
+
"turbo": {"workers":2400,"tcp_timeout":2.0,"hs_timeout":5.0,"render_hz":4.0},
|
|
9
|
+
}
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class Config:
|
|
12
|
+
workers:int=1200
|
|
13
|
+
tcp_timeout:float=3.0
|
|
14
|
+
hs_timeout:float=6.0
|
|
15
|
+
target_host:str="api.trongrid.io"
|
|
16
|
+
target_port:int=443
|
|
17
|
+
render_hz:float=3.0
|
|
18
|
+
sample_size:int=10
|
|
19
|
+
source_dir:str="."
|
|
20
|
+
output_dir:str="."
|
|
21
|
+
format:str="txt"
|
|
22
|
+
protocols:tuple=("http","socks4","socks5")
|
|
23
|
+
notify:bool=True
|
|
24
|
+
wake_lock:bool=True
|
|
25
|
+
verify:bool=False
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_toml(cls,path):
|
|
28
|
+
raw=tomllib.loads(Path(path).read_text("utf-8"))
|
|
29
|
+
valid={f.name for f in fields(cls)}
|
|
30
|
+
unknown=set(raw)-valid
|
|
31
|
+
if unknown: raise ValueError(f"unknown keys: {sorted(unknown)}")
|
|
32
|
+
if "protocols" in raw: raw["protocols"]=tuple(raw["protocols"])
|
|
33
|
+
return cls(**raw)
|
|
34
|
+
def apply_preset(self,name):
|
|
35
|
+
if name not in PRESETS: raise ValueError(f"unknown preset: {name}")
|
|
36
|
+
return replace(self,**PRESETS[name])
|
|
37
|
+
def validate(self):
|
|
38
|
+
if self.workers<1: raise ValueError("workers must be >= 1")
|
|
39
|
+
if not (1<=self.target_port<=65535): raise ValueError("target_port out of range")
|
|
40
|
+
if self.format not in {"txt","json","csv"}: raise ValueError(f"bad format: {self.format}")
|
|
41
|
+
for p in self.protocols:
|
|
42
|
+
if p not in {"http","socks4","socks5"}: raise ValueError(f"bad protocol: {p}")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import asyncio, contextlib
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from .metrics import Meter
|
|
5
|
+
from .protocols import REGISTRY
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class Hit:
|
|
8
|
+
endpoint:str
|
|
9
|
+
kind:str
|
|
10
|
+
latency_ms:float
|
|
11
|
+
class Scan:
|
|
12
|
+
def __init__(self,kind,source,sink,cfg,on_hit=None):
|
|
13
|
+
self.kind=kind; self.source=source; self.sink=sink; self.cfg=cfg
|
|
14
|
+
self.on_hit=on_hit; self.meter=Meter()
|
|
15
|
+
self._q=asyncio.Queue(); self._fh=None; self.hits=[]
|
|
16
|
+
def _load(self):
|
|
17
|
+
if not self.source.exists(): return []
|
|
18
|
+
seen=set(); out=[]
|
|
19
|
+
for raw in self.source.read_text("utf-8",errors="ignore").splitlines():
|
|
20
|
+
l=raw.strip()
|
|
21
|
+
if l and l not in seen:
|
|
22
|
+
seen.add(l); out.append(l)
|
|
23
|
+
return out
|
|
24
|
+
async def run(self):
|
|
25
|
+
e=self._load(); self.meter.total=len(e)
|
|
26
|
+
if not e: return
|
|
27
|
+
for x in e: self._q.put_nowait(x)
|
|
28
|
+
self._fh=self.sink.open("w",buffering=1)
|
|
29
|
+
v=REGISTRY[self.kind]; cfg=self.cfg
|
|
30
|
+
async def consume():
|
|
31
|
+
while True:
|
|
32
|
+
try: entry=self._q.get_nowait()
|
|
33
|
+
except asyncio.QueueEmpty: return
|
|
34
|
+
try:
|
|
35
|
+
h,_,ps=entry.rpartition(":")
|
|
36
|
+
if not h or not ps.isdigit():
|
|
37
|
+
self.meter.tick(ok=False); continue
|
|
38
|
+
lat=await v(h,int(ps),cfg.target_host,cfg.target_port,
|
|
39
|
+
tcp_timeout=cfg.tcp_timeout,
|
|
40
|
+
hs_timeout=cfg.hs_timeout,
|
|
41
|
+
verify=cfg.verify)
|
|
42
|
+
if lat is None:
|
|
43
|
+
self.meter.tick(ok=False)
|
|
44
|
+
else:
|
|
45
|
+
self.meter.tick(ok=True,latency_ms=lat)
|
|
46
|
+
self._fh.write(entry+"\n")
|
|
47
|
+
self.hits.append((entry,lat))
|
|
48
|
+
if self.on_hit:
|
|
49
|
+
with contextlib.suppress(Exception):
|
|
50
|
+
self.on_hit(Hit(entry,self.kind,lat))
|
|
51
|
+
except asyncio.CancelledError: return
|
|
52
|
+
except Exception: self.meter.tick(ok=False)
|
|
53
|
+
finally: self._q.task_done()
|
|
54
|
+
ws=[asyncio.create_task(consume()) for _ in range(cfg.workers)]
|
|
55
|
+
try: await asyncio.gather(*ws)
|
|
56
|
+
finally:
|
|
57
|
+
for t in ws: t.cancel()
|
|
58
|
+
await asyncio.gather(*ws,return_exceptions=True)
|
|
59
|
+
if self._fh is not None: self._fh.close()
|
|
60
|
+
def discover(cfg):
|
|
61
|
+
base=Path(cfg.source_dir); found=[]
|
|
62
|
+
for p in cfg.protocols:
|
|
63
|
+
s=base/f"{p}_proxies.txt"
|
|
64
|
+
if s.exists() and s.stat().st_size>0:
|
|
65
|
+
found.append((p,s))
|
|
66
|
+
return found
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections import deque
|
|
3
|
+
from dataclasses import dataclass,field
|
|
4
|
+
_S="▁▂▃▄▅▆▇█"
|
|
5
|
+
@dataclass
|
|
6
|
+
class Meter:
|
|
7
|
+
total:int=0
|
|
8
|
+
done:int=0
|
|
9
|
+
ok:int=0
|
|
10
|
+
fail:int=0
|
|
11
|
+
started_at:float=field(default_factory=time.monotonic)
|
|
12
|
+
_w:deque=field(default_factory=lambda:deque(maxlen=60))
|
|
13
|
+
_p:int=0
|
|
14
|
+
_l:deque=field(default_factory=lambda:deque(maxlen=512))
|
|
15
|
+
def tick(self,*,ok,latency_ms=None):
|
|
16
|
+
self.done+=1
|
|
17
|
+
if ok:
|
|
18
|
+
self.ok+=1
|
|
19
|
+
if latency_ms is not None: self._l.append(latency_ms)
|
|
20
|
+
else: self.fail+=1
|
|
21
|
+
def sample(self):
|
|
22
|
+
self._w.append((time.monotonic(),self.done-self._p))
|
|
23
|
+
self._p=self.done
|
|
24
|
+
def rate(self):
|
|
25
|
+
if len(self._w)<2: return 0.0
|
|
26
|
+
t0,_=self._w[0]; t1,_=self._w[-1]
|
|
27
|
+
dt=t1-t0
|
|
28
|
+
if dt<=0: return 0.0
|
|
29
|
+
return sum(n for _,n in self._w)/dt
|
|
30
|
+
def eta(self):
|
|
31
|
+
rem=self.total-self.done; r=self.rate()
|
|
32
|
+
if r<=0 or rem<=0: return None
|
|
33
|
+
return rem/r
|
|
34
|
+
def percentile(self,p):
|
|
35
|
+
if not self._l: return None
|
|
36
|
+
d=sorted(self._l)
|
|
37
|
+
return d[min(len(d)-1,int(p*len(d)))]
|
|
38
|
+
def spark(self,w=24):
|
|
39
|
+
if not self._w: return " "*w
|
|
40
|
+
s=[n for _,n in self._w][-w:]
|
|
41
|
+
hi=max(s) or 1
|
|
42
|
+
return "".join(_S[min(7,(n*8)//hi)] for n in s).rjust(w)
|
|
43
|
+
@property
|
|
44
|
+
def elapsed(self): return time.monotonic()-self.started_at
|
|
45
|
+
@property
|
|
46
|
+
def progress(self): return self.done/self.total if self.total else 0.0
|
|
47
|
+
def fmt_dur(s):
|
|
48
|
+
if s is None: return "--:--"
|
|
49
|
+
n=int(s)
|
|
50
|
+
return f"{n//60:02d}:{n%60:02d}" if n<3600 else f"{n//3600}h{(n%3600)//60:02d}m"
|
|
51
|
+
def fmt_int(n): return f"{n:,}".replace(","," ")
|
|
52
|
+
def fmt_uptime(s):
|
|
53
|
+
s=int(s); h,r=divmod(s,3600); m,sec=divmod(r,60)
|
|
54
|
+
return f"{h:02d}:{m:02d}:{sec:02d}"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import os, shutil, subprocess
|
|
2
|
+
def _have(c): return shutil.which(c) is not None
|
|
3
|
+
def is_termux(): return "com.termux" in os.environ.get("PREFIX","")
|
|
4
|
+
def wake_lock():
|
|
5
|
+
if not _have("termux-wake-lock"): return False
|
|
6
|
+
try:
|
|
7
|
+
subprocess.run(["termux-wake-lock"],check=False,
|
|
8
|
+
stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
|
9
|
+
return True
|
|
10
|
+
except Exception: return False
|
|
11
|
+
def wake_unlock():
|
|
12
|
+
if not _have("termux-wake-unlock"): return
|
|
13
|
+
try:
|
|
14
|
+
subprocess.run(["termux-wake-unlock"],check=False,
|
|
15
|
+
stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
|
16
|
+
except Exception: pass
|
|
17
|
+
def notify(t,b):
|
|
18
|
+
if not _have("termux-notification"): return
|
|
19
|
+
try:
|
|
20
|
+
subprocess.run(["termux-notification","--title",t,"--content",b,
|
|
21
|
+
"--priority","high"],check=False,
|
|
22
|
+
stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
|
23
|
+
except Exception: pass
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import asyncio, socket
|
|
2
|
+
from time import perf_counter
|
|
3
|
+
async def _d(h,p,t):
|
|
4
|
+
return await asyncio.wait_for(asyncio.open_connection(h,p),timeout=t)
|
|
5
|
+
async def _tun(host,port,th,tp,tt,ht,verify=False):
|
|
6
|
+
r=w=None; t0=perf_counter()
|
|
7
|
+
try:
|
|
8
|
+
r,w=await _d(host,port,tt)
|
|
9
|
+
req=(f"CONNECT {th}:{tp} HTTP/1.1\r\nHost: {th}:{tp}\r\n"
|
|
10
|
+
f"Proxy-Connection: keep-alive\r\n\r\n")
|
|
11
|
+
w.write(req.encode()); await w.drain()
|
|
12
|
+
line=await asyncio.wait_for(r.readline(),timeout=ht)
|
|
13
|
+
if not line.startswith(b"HTTP/"): return None
|
|
14
|
+
parts=line.split(b" ",2)
|
|
15
|
+
if len(parts)<2 or parts[1]!=b"200": return None
|
|
16
|
+
# ── verify phase: real request through the tunnel ──
|
|
17
|
+
if verify:
|
|
18
|
+
v = await _verify_through(r,w,th,tp,ht)
|
|
19
|
+
if v is None: return None
|
|
20
|
+
return (perf_counter()-t0)*1000.0
|
|
21
|
+
except (OSError,asyncio.TimeoutError,asyncio.IncompleteReadError): return None
|
|
22
|
+
finally:
|
|
23
|
+
if w is not None: w.close()
|
|
24
|
+
async def _verify_through(r,w,th,tp,ht):
|
|
25
|
+
"""Send a real HTTP/1.0 request through the established tunnel.
|
|
26
|
+
Any HTTP status line at all means the proxy is actually forwarding."""
|
|
27
|
+
try:
|
|
28
|
+
req=(f"HEAD / HTTP/1.0\r\nHost: {th}\r\n"
|
|
29
|
+
f"User-Agent: vzrecon/3.0\r\n\r\n")
|
|
30
|
+
w.write(req.encode()); await w.drain()
|
|
31
|
+
line=await asyncio.wait_for(r.readline(),timeout=ht)
|
|
32
|
+
if not line.startswith(b"HTTP/"): return None
|
|
33
|
+
return 1
|
|
34
|
+
except (OSError,asyncio.TimeoutError,asyncio.IncompleteReadError):
|
|
35
|
+
return None
|
|
36
|
+
async def _fwd(host,port,th,tt,ht):
|
|
37
|
+
r=w=None; t0=perf_counter()
|
|
38
|
+
try:
|
|
39
|
+
r,w=await _d(host,port,tt)
|
|
40
|
+
req=(f"GET http://{th}/ HTTP/1.1\r\nHost: {th}\r\n"
|
|
41
|
+
f"User-Agent: vzrecon/3.0\r\nConnection: close\r\n\r\n")
|
|
42
|
+
w.write(req.encode()); await w.drain()
|
|
43
|
+
line=await asyncio.wait_for(r.readline(),timeout=ht)
|
|
44
|
+
if not line.startswith(b"HTTP/"): return None
|
|
45
|
+
return (perf_counter()-t0)*1000.0
|
|
46
|
+
except (OSError,asyncio.TimeoutError,asyncio.IncompleteReadError): return None
|
|
47
|
+
finally:
|
|
48
|
+
if w is not None: w.close()
|
|
49
|
+
async def http_connect(host,port,th,tp,*,tcp_timeout,hs_timeout,verify=False):
|
|
50
|
+
r=await _tun(host,port,th,tp,tcp_timeout,hs_timeout,verify=verify)
|
|
51
|
+
if r is not None: return r
|
|
52
|
+
# forward-GET is already a real request; no extra verify needed
|
|
53
|
+
return await _fwd(host,port,th,tcp_timeout,hs_timeout)
|
|
54
|
+
async def socks5_connect(host,port,th,tp,*,tcp_timeout,hs_timeout,verify=False):
|
|
55
|
+
r=w=None; t0=perf_counter()
|
|
56
|
+
try:
|
|
57
|
+
r,w=await _d(host,port,tcp_timeout)
|
|
58
|
+
w.write(b"\x05\x01\x00"); await w.drain()
|
|
59
|
+
g=await asyncio.wait_for(r.readexactly(2),timeout=hs_timeout)
|
|
60
|
+
if g!=b"\x05\x00": return None
|
|
61
|
+
n=th.encode("idna")
|
|
62
|
+
if len(n)>255: return None
|
|
63
|
+
w.write(b"\x05\x01\x00\x03"+bytes((len(n),))+n+tp.to_bytes(2,"big"))
|
|
64
|
+
await w.drain()
|
|
65
|
+
h=await asyncio.wait_for(r.readexactly(4),timeout=hs_timeout)
|
|
66
|
+
if h[0]!=0x05 or h[1]!=0x00: return None
|
|
67
|
+
a=h[3]
|
|
68
|
+
if a==0x01: tail=6
|
|
69
|
+
elif a==0x04: tail=18
|
|
70
|
+
elif a==0x03:
|
|
71
|
+
ln=await asyncio.wait_for(r.readexactly(1),timeout=hs_timeout)
|
|
72
|
+
tail=ln[0]+2
|
|
73
|
+
else: return None
|
|
74
|
+
await asyncio.wait_for(r.readexactly(tail),timeout=hs_timeout)
|
|
75
|
+
if verify:
|
|
76
|
+
v = await _verify_through(r,w,th,tp,hs_timeout)
|
|
77
|
+
if v is None: return None
|
|
78
|
+
return (perf_counter()-t0)*1000.0
|
|
79
|
+
except (OSError,asyncio.TimeoutError,asyncio.IncompleteReadError): return None
|
|
80
|
+
finally:
|
|
81
|
+
if w is not None: w.close()
|
|
82
|
+
async def socks4_connect(host,port,th,tp,*,tcp_timeout,hs_timeout,verify=False):
|
|
83
|
+
r=w=None; t0=perf_counter()
|
|
84
|
+
try:
|
|
85
|
+
try: ipv4=socket.gethostbyname(th)
|
|
86
|
+
except OSError: return None
|
|
87
|
+
octets=bytes(int(x) for x in ipv4.split("."))
|
|
88
|
+
r,w=await _d(host,port,tcp_timeout)
|
|
89
|
+
w.write(b"\x04\x01"+tp.to_bytes(2,"big")+octets+b"\x00")
|
|
90
|
+
await w.drain()
|
|
91
|
+
rep=await asyncio.wait_for(r.readexactly(8),timeout=hs_timeout)
|
|
92
|
+
if rep[0]!=0x00 or rep[1]!=0x5A: return None
|
|
93
|
+
if verify:
|
|
94
|
+
v = await _verify_through(r,w,th,tp,hs_timeout)
|
|
95
|
+
if v is None: return None
|
|
96
|
+
return (perf_counter()-t0)*1000.0
|
|
97
|
+
except (OSError,asyncio.TimeoutError,asyncio.IncompleteReadError): return None
|
|
98
|
+
finally:
|
|
99
|
+
if w is not None: w.close()
|
|
100
|
+
REGISTRY={"http":http_connect,"socks4":socks4_connect,"socks5":socks5_connect}
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import asyncio, contextlib
|
|
2
|
+
from .protocols import http_connect, socks4_connect, socks5_connect
|
|
3
|
+
async def _mh(r,w):
|
|
4
|
+
try:
|
|
5
|
+
l=await r.readline()
|
|
6
|
+
if l.startswith(b"CONNECT"):
|
|
7
|
+
w.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
|
|
8
|
+
await w.drain()
|
|
9
|
+
while True:
|
|
10
|
+
h=await r.readline()
|
|
11
|
+
if h in (b"\r\n",b"\n",b""): break
|
|
12
|
+
finally: w.close()
|
|
13
|
+
async def _m5(r,w):
|
|
14
|
+
try:
|
|
15
|
+
g=await r.readexactly(2)
|
|
16
|
+
if g!=b"\x05\x01": return
|
|
17
|
+
await r.readexactly(1)
|
|
18
|
+
w.write(b"\x05\x00"); await w.drain()
|
|
19
|
+
h=await r.readexactly(4); a=h[3]
|
|
20
|
+
if a==0x01: await r.readexactly(6)
|
|
21
|
+
elif a==0x03:
|
|
22
|
+
n=(await r.readexactly(1))[0]
|
|
23
|
+
await r.readexactly(n+2)
|
|
24
|
+
elif a==0x04: await r.readexactly(18)
|
|
25
|
+
w.write(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
|
|
26
|
+
await w.drain()
|
|
27
|
+
finally: w.close()
|
|
28
|
+
async def _m4(r,w):
|
|
29
|
+
try:
|
|
30
|
+
await r.readexactly(8)
|
|
31
|
+
w.write(b"\x00\x5a\x00\x00\x00\x00\x00\x00")
|
|
32
|
+
await w.drain()
|
|
33
|
+
finally: w.close()
|
|
34
|
+
CASES=[("http",_mh,http_connect),
|
|
35
|
+
("socks5",_m5,socks5_connect),
|
|
36
|
+
("socks4",_m4,socks4_connect)]
|
|
37
|
+
async def _serve(h):
|
|
38
|
+
srv=await asyncio.start_server(h,"127.0.0.1",0)
|
|
39
|
+
return srv,srv.sockets[0].getsockname()[1]
|
|
40
|
+
async def run():
|
|
41
|
+
fail=0
|
|
42
|
+
for name,handler,v in CASES:
|
|
43
|
+
srv,port=await _serve(handler)
|
|
44
|
+
try:
|
|
45
|
+
lat=await v("127.0.0.1",port,"example.com",443,
|
|
46
|
+
tcp_timeout=2.0,hs_timeout=2.0)
|
|
47
|
+
ok=lat is not None
|
|
48
|
+
print(f" {name:<7} {'ok ' if ok else 'FAIL'} {lat or 0:>7.2f} ms")
|
|
49
|
+
if not ok: fail+=1
|
|
50
|
+
finally:
|
|
51
|
+
srv.close()
|
|
52
|
+
with contextlib.suppress(Exception):
|
|
53
|
+
await srv.wait_closed()
|
|
54
|
+
return 0 if fail==0 else 1
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import shutil, sys
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from .brand import BANNER, CODENAME, CREW, SUFFIX, TAGLINE, TOOL, SGR
|
|
4
|
+
from .metrics import fmt_dur, fmt_int, fmt_uptime
|
|
5
|
+
|
|
6
|
+
def _vis(s):
|
|
7
|
+
n = 0; i = 0
|
|
8
|
+
while i < len(s):
|
|
9
|
+
if s[i] == "\x1b":
|
|
10
|
+
j = s.find("m", i)
|
|
11
|
+
if j == -1: break
|
|
12
|
+
i = j + 1; continue
|
|
13
|
+
n += 1; i += 1
|
|
14
|
+
return n
|
|
15
|
+
|
|
16
|
+
def _pad(s, w):
|
|
17
|
+
v = _vis(s)
|
|
18
|
+
return s if v >= w else s + " " * (w - v)
|
|
19
|
+
|
|
20
|
+
def _hr(l, c, r, ch, w):
|
|
21
|
+
return f"{SGR.BLOOD}{l}{ch * w}{r}{SGR.R}"
|
|
22
|
+
|
|
23
|
+
def _box_top(title, w):
|
|
24
|
+
t = f" {title} "
|
|
25
|
+
pad = w - _vis(t) - 2
|
|
26
|
+
return f"{SGR.BLOOD}┌─{r}{SGR.CRIM}{SGR.B}{t}{SGR.R}{SGR.BLOOD}{'─' * pad}─┐{SGR.R}"
|
|
27
|
+
|
|
28
|
+
def _box_bot(w):
|
|
29
|
+
return f"{SGR.BLOOD}└{'─' * w}┘{SGR.R}"
|
|
30
|
+
|
|
31
|
+
def _box_mid(s, w):
|
|
32
|
+
v = _vis(s)
|
|
33
|
+
return f"{SGR.BLOOD}│{SGR.R} {s}{' ' * (w - v - 1)}{SGR.BLOOD}│{SGR.R}"
|
|
34
|
+
|
|
35
|
+
class TUI:
|
|
36
|
+
def __init__(self, scans, target, workers, sid, mode):
|
|
37
|
+
self.scans = scans
|
|
38
|
+
self.target = target
|
|
39
|
+
self.workers = workers
|
|
40
|
+
self.sid = sid
|
|
41
|
+
self.mode = mode
|
|
42
|
+
self._prev = ""
|
|
43
|
+
self._hits = []
|
|
44
|
+
self._cap = 7
|
|
45
|
+
|
|
46
|
+
def collect(self, h):
|
|
47
|
+
self._hits.append(h)
|
|
48
|
+
if len(self._hits) > 200:
|
|
49
|
+
self._hits = self._hits[-200:]
|
|
50
|
+
|
|
51
|
+
def attach(self):
|
|
52
|
+
for s in self.scans:
|
|
53
|
+
s.on_hit = self.collect
|
|
54
|
+
|
|
55
|
+
def _w(self):
|
|
56
|
+
try:
|
|
57
|
+
return max(64, min(shutil.get_terminal_size((80, 24)).columns - 2, 84))
|
|
58
|
+
except OSError:
|
|
59
|
+
return 78
|
|
60
|
+
|
|
61
|
+
def _header(self, W):
|
|
62
|
+
o = []
|
|
63
|
+
r = SGR.R
|
|
64
|
+
cap = f"{SGR.CRIM}{SGR.B}{CREW}{r} {SGR.IRON}·{r} {SGR.GOLD}{TOOL} 3.0.0{r} {SGR.IRON}·{r} {SGR.SLATE}{CODENAME}{r}"
|
|
65
|
+
left = f" {cap} "
|
|
66
|
+
right = f" {SGR.IRON}{TAGLINE}{r} "
|
|
67
|
+
pad = W - _vis(left) - _vis(right) - 2
|
|
68
|
+
o.append(f"{SGR.BLOOD}┌{'─' * (W - 2)}┐{r}")
|
|
69
|
+
o.append(f"{SGR.BLOOD}│{r}{left}{' ' * pad}{right}{SGR.BLOOD}│{r}")
|
|
70
|
+
for ln in BANNER:
|
|
71
|
+
p = (W - 2 - len(ln)) // 2
|
|
72
|
+
o.append(
|
|
73
|
+
f"{SGR.BLOOD}│{r}{' ' * max(0, p)}{SGR.CRIM}{SGR.B}{ln}{r}"
|
|
74
|
+
f"{' ' * max(0, W - 2 - p - len(ln))}{SGR.BLOOD}│{r}"
|
|
75
|
+
)
|
|
76
|
+
sub = f"{CREW} // {SUFFIX}"
|
|
77
|
+
p = (W - 2 - len(sub)) // 2
|
|
78
|
+
o.append(
|
|
79
|
+
f"{SGR.BLOOD}│{r}{' ' * p}{SGR.SLATE}{sub}{r}"
|
|
80
|
+
f"{' ' * (W - 2 - p - len(sub))}{SGR.BLOOD}│{r}"
|
|
81
|
+
)
|
|
82
|
+
o.append(_box_bot(W - 2))
|
|
83
|
+
return o
|
|
84
|
+
|
|
85
|
+
def _strip(self, W):
|
|
86
|
+
r = SGR.R
|
|
87
|
+
o = []
|
|
88
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
|
|
89
|
+
up = fmt_uptime(self.scans[0].meter.elapsed) if self.scans else "00:00:00"
|
|
90
|
+
row1 = (
|
|
91
|
+
f"{SGR.IRON}session{r} {SGR.GOLD}{SGR.B}{self.sid}{r}"
|
|
92
|
+
f" {SGR.IRON}uptime{r} {SGR.IVORY}{up}{r}"
|
|
93
|
+
f" {SGR.IRON}utc{r} {SGR.IVORY}{now}{r}"
|
|
94
|
+
)
|
|
95
|
+
row2 = (
|
|
96
|
+
f"{SGR.IRON}target{r} {SGR.IVORY}{self.target}{r}"
|
|
97
|
+
f" {SGR.IRON}pool{r} {SGR.IVORY}{self.workers}{r}"
|
|
98
|
+
f" {SGR.IRON}mode{r} {SGR.AMBER}{self.mode}{r}"
|
|
99
|
+
)
|
|
100
|
+
o.append(f" {row1}")
|
|
101
|
+
o.append(f" {row2}")
|
|
102
|
+
o.append("")
|
|
103
|
+
return o
|
|
104
|
+
|
|
105
|
+
def _panel(self, s, W):
|
|
106
|
+
r = SGR.R
|
|
107
|
+
o = []
|
|
108
|
+
m = s.meter
|
|
109
|
+
kind = s.kind.upper()
|
|
110
|
+
head = f"── {kind} "
|
|
111
|
+
rule = W - _vis(head) - 4
|
|
112
|
+
o.append(f" {SGR.CRIM}{SGR.B}{head}{r}{SGR.BLOOD}{'─' * max(0, rule)}{r}")
|
|
113
|
+
bar_w = W - 32
|
|
114
|
+
frac = m.progress
|
|
115
|
+
fill = int(frac * bar_w)
|
|
116
|
+
bar = f"{SGR.ICE}{'█' * fill}{SGR.IRON}{'░' * (bar_w - fill)}{r}"
|
|
117
|
+
p95 = m.percentile(0.95)
|
|
118
|
+
p95s = f"{p95:>5.0f}ms" if p95 is not None else " --"
|
|
119
|
+
eta = fmt_dur(m.eta())
|
|
120
|
+
o.append(
|
|
121
|
+
f" {SGR.IRON}live{r} {SGR.GREEN}{SGR.B}{m.ok:>6}{r}"
|
|
122
|
+
f" {SGR.IRON}dead{r} {SGR.CRIM}{m.fail:>6}{r}"
|
|
123
|
+
f" {SGR.IRON}rate{r} {SGR.IVORY}{m.rate():>6.0f}/s{r}"
|
|
124
|
+
f" {SGR.IRON}eta{r} {SGR.IVORY}{eta}{r}"
|
|
125
|
+
f" {SGR.IRON}p95{r} {SGR.ASH}{p95s}{r}"
|
|
126
|
+
)
|
|
127
|
+
o.append(f" {bar} {SGR.ASH}{frac * 100:>5.1f}%{r}")
|
|
128
|
+
o.append(f" {SGR.ICE}{m.spark(bar_w)}{r}")
|
|
129
|
+
o.append("")
|
|
130
|
+
return o
|
|
131
|
+
|
|
132
|
+
def _summary(self, W):
|
|
133
|
+
r = SGR.R
|
|
134
|
+
o = []
|
|
135
|
+
ok_t = sum(s.meter.ok for s in self.scans)
|
|
136
|
+
done_t = sum(s.meter.done for s in self.scans)
|
|
137
|
+
all_t = sum(s.meter.total for s in self.scans)
|
|
138
|
+
pct = (ok_t / done_t * 100.0) if done_t else 0.0
|
|
139
|
+
o.append(f" {SGR.BLOOD}{'─' * (W - 4)}{r}")
|
|
140
|
+
o.append(
|
|
141
|
+
f" {SGR.IRON}alive{r} {SGR.VERD}{SGR.B}{fmt_int(ok_t)}{r}"
|
|
142
|
+
f" {SGR.IRON}checked{r} {SGR.ASH}{fmt_int(done_t)}/{fmt_int(all_t)}{r}"
|
|
143
|
+
f" {SGR.IRON}hit-rate{r} {SGR.GOLD}{pct:.2f}%{r}"
|
|
144
|
+
)
|
|
145
|
+
o.append("")
|
|
146
|
+
return o
|
|
147
|
+
|
|
148
|
+
def _live(self, W):
|
|
149
|
+
r = SGR.R
|
|
150
|
+
o = []
|
|
151
|
+
if not self._hits:
|
|
152
|
+
return o
|
|
153
|
+
head = f"── live hits "
|
|
154
|
+
rule = W - _vis(head) - 4
|
|
155
|
+
o.append(f" {SGR.CRIM}{SGR.B}{head}{r}{SGR.BLOOD}{'─' * max(0, rule)}{r}")
|
|
156
|
+
for h in self._hits[-self._cap:]:
|
|
157
|
+
if h.latency_ms < 200:
|
|
158
|
+
c = SGR.GREEN
|
|
159
|
+
elif h.latency_ms < 800:
|
|
160
|
+
c = SGR.AMBER
|
|
161
|
+
else:
|
|
162
|
+
c = SGR.CRIM
|
|
163
|
+
o.append(
|
|
164
|
+
f" {SGR.CRIM}▸{r} {SGR.ICE}{h.kind:<7}{r}"
|
|
165
|
+
f"{SGR.IVORY}{h.endpoint:<28}{r}"
|
|
166
|
+
f"{c}{h.latency_ms:>6.0f} ms{r}"
|
|
167
|
+
)
|
|
168
|
+
o.append("")
|
|
169
|
+
return o
|
|
170
|
+
|
|
171
|
+
def _footer(self, W):
|
|
172
|
+
r = SGR.R
|
|
173
|
+
o = []
|
|
174
|
+
sig = f"{CREW} // {SUFFIX}"
|
|
175
|
+
p = (W - _vis(sig)) // 2
|
|
176
|
+
o.append(f"{' ' * max(0, p)}{SGR.BLOOD}{SGR.I}{sig}{r}")
|
|
177
|
+
return o
|
|
178
|
+
|
|
179
|
+
def _render(self):
|
|
180
|
+
W = self._w()
|
|
181
|
+
o = []
|
|
182
|
+
o += self._header(W)
|
|
183
|
+
o.append("")
|
|
184
|
+
o += self._strip(W)
|
|
185
|
+
for s in self.scans:
|
|
186
|
+
o += self._panel(s, W)
|
|
187
|
+
o += self._summary(W)
|
|
188
|
+
o += self._live(W)
|
|
189
|
+
o += self._footer(W)
|
|
190
|
+
return "\n".join(o)
|
|
191
|
+
|
|
192
|
+
def draw(self):
|
|
193
|
+
f = self._render()
|
|
194
|
+
if f == self._prev:
|
|
195
|
+
return
|
|
196
|
+
self._prev = f
|
|
197
|
+
sys.stdout.write("\x1b[H\x1b[2J" + f)
|
|
198
|
+
sys.stdout.flush()
|
|
199
|
+
|
|
200
|
+
def enter(self):
|
|
201
|
+
sys.stdout.write("\x1b[?1049h\x1b[?25l")
|
|
202
|
+
sys.stdout.flush()
|
|
203
|
+
|
|
204
|
+
def leave(self):
|
|
205
|
+
sys.stdout.write("\x1b[?25h\x1b[?1049l")
|
|
206
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import csv, json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .brand import CREW
|
|
4
|
+
def emit_txt(p,hits):
|
|
5
|
+
b="\n".join(e for e,_ in hits)
|
|
6
|
+
Path(p).write_text(b+("\n" if b else ""),encoding="utf-8")
|
|
7
|
+
def emit_json(p,kind,hits):
|
|
8
|
+
payload={"signed_by":CREW,"protocol":kind,"count":len(hits),
|
|
9
|
+
"proxies":[{"endpoint":e,"latency_ms":round(l,2)} for e,l in hits]}
|
|
10
|
+
Path(p).write_text(json.dumps(payload,indent=2),encoding="utf-8")
|
|
11
|
+
def emit_csv(p,hits):
|
|
12
|
+
with Path(p).open("w",newline="",encoding="utf-8") as f:
|
|
13
|
+
w=csv.writer(f); w.writerow(["endpoint","latency_ms"])
|
|
14
|
+
for e,l in hits: w.writerow([e,f"{l:.2f}"])
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vzrecon
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Protocol-level validator for HTTP/SOCKS4/SOCKS5 proxies
|
|
5
|
+
Author: VZLOI GROUP
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/vzloi/vzrecon
|
|
8
|
+
Project-URL: Source, https://github.com/vzloi/vzrecon
|
|
9
|
+
Project-URL: Issues, https://github.com/vzloi/vzrecon/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/vzloi/vzrecon/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: proxy,socks,socks5,socks4,validator,recon,async
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: Operating System :: POSIX
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Internet
|
|
22
|
+
Classifier: Topic :: System :: Networking
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# vzrecon
|
|
33
|
+
|
|
34
|
+
Protocol-level validator for HTTP, SOCKS4 and SOCKS5 proxies.
|
|
35
|
+
Zero dependencies. Termux-native.
|
|
36
|
+
|
|
37
|
+
Signed: VZLOI GROUP // ops
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
pip install -e .
|
|
42
|
+
vzrecon --self-test
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
vzrecon # scan cwd for *_proxies.txt
|
|
47
|
+
vzrecon -d ./lists -o ./out # explicit paths
|
|
48
|
+
vzrecon --preset stealth # 200 workers
|
|
49
|
+
vzrecon --preset turbo # 2400 workers
|
|
50
|
+
vzrecon --only http,socks5
|
|
51
|
+
vzrecon --plain
|
|
52
|
+
|
|
53
|
+
## Protocols
|
|
54
|
+
|
|
55
|
+
- HTTP RFC 7231 CONNECT + forward GET fallback
|
|
56
|
+
- SOCKS5 RFC 1928, no-auth greeting
|
|
57
|
+
- SOCKS4 original spec, IPv4 targets only
|
|
58
|
+
|
|
59
|
+
## Output
|
|
60
|
+
|
|
61
|
+
- `<proto>_alive.txt` — endpoints, one per line
|
|
62
|
+
- `<proto>_alive.json` — signed_by, protocol, count, proxies[]
|
|
63
|
+
- `<proto>_alive.csv` — endpoint, latency_ms
|
|
64
|
+
|
|
65
|
+
## Exit codes
|
|
66
|
+
|
|
67
|
+
- 0 scan completed
|
|
68
|
+
- 1 self-test failed
|
|
69
|
+
- 2 config error
|
|
70
|
+
- 3 no source files found
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT. Copyright (c) 2026 VZLOI GROUP.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tests/test_config.py
|
|
5
|
+
tests/test_metrics.py
|
|
6
|
+
tests/test_protocols.py
|
|
7
|
+
vzrecon/__init__.py
|
|
8
|
+
vzrecon/__main__.py
|
|
9
|
+
vzrecon/brand.py
|
|
10
|
+
vzrecon/cli.py
|
|
11
|
+
vzrecon/config.py
|
|
12
|
+
vzrecon/engine.py
|
|
13
|
+
vzrecon/metrics.py
|
|
14
|
+
vzrecon/platform.py
|
|
15
|
+
vzrecon/protocols.py
|
|
16
|
+
vzrecon/py.typed
|
|
17
|
+
vzrecon/selftest.py
|
|
18
|
+
vzrecon/tui.py
|
|
19
|
+
vzrecon/writers.py
|
|
20
|
+
vzrecon.egg-info/PKG-INFO
|
|
21
|
+
vzrecon.egg-info/SOURCES.txt
|
|
22
|
+
vzrecon.egg-info/dependency_links.txt
|
|
23
|
+
vzrecon.egg-info/entry_points.txt
|
|
24
|
+
vzrecon.egg-info/requires.txt
|
|
25
|
+
vzrecon.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vzrecon
|