arblant
railway-clean-ip-scanner-pc / scanner.py
scanner.py292 lines · 10.4 KB
1 2 3 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 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 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
"""Scanner engine — faithful port of VlessScanner.kt (MIT, Unknown37841).

Original: app/src/main/java/com/spider/railwayipscanner/VlessScanner.kt (219 lines)

Protocol (unchanged from the Android original):
  1. TCP connect to IP:port (default 443), timeout 2400 ms
  2. TLS handshake with custom SNI = domain, ALPN http/1.1, trust-all certs
  3. WebSocket upgrade GET <path>, expect HTTP 101, headers read strictly
     until \\r\\n\\r\\n
  4. Binary VLESS header + HTTP GET /generate_204 payload, sent as a masked
     WebSocket binary frame
  5. Success only if the response contains "HTTP/1.1 204" or "204 No Content"

Verification (test_ip_real_delay): 3 consecutive probes, 60 ms sleep between,
average delay; a drop on the 2nd/3rd probe is reported as "Unstable".

Stdlib only: socket, ssl, threading / concurrent.futures, time, uuid.
"""
from __future__ import annotations

import socket
import ssl
import time
import uuid as uuid_module
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Callable, Optional

DEFAULT_TIMEOUT_MS = 2400
WS_MASK = bytes([0x12, 0x34, 0x56, 0x78])
TARGET_HOST = b"www.google.com"
TARGET_PORT = 80


@dataclass
class ScanResult:
    ip: str
    is_success: bool
    delay_ms: int = 0
    error: Optional[str] = None


# ---------------------------------------------------------------------------
# Packet / frame construction (separated so it can be unit-tested offline)
# ---------------------------------------------------------------------------

def build_vless_header(uuid_string: str, target_host: bytes = TARGET_HOST,
                       target_port: int = TARGET_PORT) -> bytes:
    """Binary VLESS header, byte-for-byte as in VlessScanner.kt:

    [0] version 0 | [1:17] UUID (16 bytes) | addon length 0 | command 1 (TCP)
    | port (2 bytes, big-endian) | address type 2 (domain) | host length | host
    """
    uuid_bytes = uuid_module.UUID(uuid_string).bytes  # 16 bytes, MSB-first
    header = bytearray()
    header.append(0)                       # Version 0
    header.extend(uuid_bytes)              # UUID auth
    header.append(0)                       # Proto addon length
    header.append(1)                       # Command: 1 = TCP
    header.append((target_port >> 8) & 0xFF)
    header.append(target_port & 0xFF)      # Port 80 in the original
    header.append(2)                       # Address Type: Domain
    header.append(len(target_host) & 0xFF)
    header.extend(target_host)
    return bytes(header)


def build_http_payload() -> bytes:
    crlf = "\r\n"
    return (
        "GET /generate_204 HTTP/1.1" + crlf +
        "Host: www.google.com" + crlf +
        "User-Agent: v2rayN" + crlf +
        "Connection: close" + crlf + crlf
    ).encode("utf-8")


def build_full_vless_data(uuid_string: str) -> bytes:
    return build_vless_header(uuid_string) + build_http_payload()


def build_ws_frame(data: bytes, mask: bytes = WS_MASK) -> bytes:
    """Masked WebSocket client binary frame, as in VlessScanner.kt.

    First byte 0x82 (FIN + binary), second byte 0x80|len (<126) or
    0x80|126 + 2-byte length, then the 4-byte mask, then masked payload.
    """
    masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
    if len(data) < 126:
        frame_header = bytes([0x82, 0x80 | len(data)]) + mask
    else:
        frame_header = bytes([
            0x82,
            0x80 | 126,
            (len(data) >> 8) & 0xFF,
            len(data) & 0xFF,
        ]) + mask
    return frame_header + masked


def build_ws_upgrade_request(domain: str, path: str) -> bytes:
    clean_path = path if path.startswith("/") else "/" + path
    crlf = "\r\n"
    ws_req = (
        "GET " + clean_path + " HTTP/1.1" + crlf +
        "Host: " + domain + crlf +
        "Upgrade: websocket" + crlf +
        "Connection: Upgrade" + crlf +
        "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" + crlf +
        "Sec-WebSocket-Version: 13" + crlf +
        "User-Agent: Mozilla/5.0 (Android; SpiderPanel)" + crlf + crlf
    )
    return ws_req.encode("utf-8")


def _read_until_double_crlf(recv_byte: Callable[[], Optional[int]]) -> Optional[bytes]:
    """Read one byte at a time until \\r\\n\\r\\n — mirrors readUntilDoubleCRLF()."""
    buffer = bytearray()
    pattern = bytes([13, 10, 13, 10])
    matched = 0
    max_bytes = 4096
    total_read = 0
    while total_read < max_bytes:
        b = recv_byte()
        if b is None or b == -1:
            break
        total_read += 1
        buffer.append(b)
        if b == pattern[matched]:
            matched += 1
            if matched == len(pattern):
                return bytes(buffer)
        else:
            matched = 1 if b == pattern[0] else 0
    return bytes(buffer) if len(buffer) > 0 else None


# ---------------------------------------------------------------------------
# Probing
# ---------------------------------------------------------------------------

def _make_ssl_context() -> ssl.SSLContext:
    # Trust-all, exactly like the original's X509TrustManager that accepts all.
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    try:
        ctx.set_alpn_protocols(["http/1.1"])
    except Exception:
        pass
    return ctx


_SSL_CONTEXT = None


def _ssl_context() -> ssl.SSLContext:
    global _SSL_CONTEXT
    if _SSL_CONTEXT is None:
        _SSL_CONTEXT = _make_ssl_context()
    return _SSL_CONTEXT


def run_single_probe(ip: str, domain: str, uuid_string: str,
                     path: str = "/", port: int = 443) -> ScanResult:
    t0 = time.time()
    raw_socket = None
    ssl_socket = None
    try:
        timeout_s = DEFAULT_TIMEOUT_MS / 1000.0

        # 1. Direct TCP Connect
        raw_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        raw_socket.settimeout(timeout_s)
        raw_socket.connect((ip, port))

        # 2. TLS Handshake with Domain SNI & ALPN http/1.1
        ssl_socket = _ssl_context().wrap_socket(
            raw_socket, server_hostname=domain
        )
        ssl_socket.settimeout(timeout_s)
        raw_socket = None  # ownership transferred to ssl_socket

        out = ssl_socket
        inp = ssl_socket

        # 3. Send WebSocket Upgrade Request
        out.sendall(build_ws_upgrade_request(domain, path))

        def _recv_byte():
            data = inp.recv(1)
            if not data:
                return -1
            return data[0]

        header_bytes = _read_until_double_crlf(_recv_byte)
        if header_bytes is None:
            return ScanResult(ip, False, 0, "No WS upgrade response")
        header_str = header_bytes.decode("utf-8", errors="replace")
        first_line = header_str.splitlines()[0] if header_str.splitlines() else ""
        if "101" not in first_line:
            return ScanResult(ip, False, 0, f"WS Upgrade failed: {first_line}")

        # 4+5. Binary VLESS frame + HTTP 204 probe payload
        full_data = build_full_vless_data(uuid_string)
        out.sendall(build_ws_frame(full_data))

        # 5. Read End-to-End Google Response (single read, 1024 bytes, as original)
        resp_buf = inp.recv(1024)
        delay_ms = int((time.time() - t0) * 1000)

        if resp_buf:
            resp_str = resp_buf.decode("iso-8859-1")
            if "HTTP/1.1 204" in resp_str or "204 No Content" in resp_str:
                return ScanResult(ip, True, delay_ms)
        return ScanResult(ip, False, delay_ms, "No valid Google 204 response")

    except Exception as e:
        return ScanResult(ip, False, 0, str(e) or "Connection error")
    finally:
        try:
            if ssl_socket is not None:
                ssl_socket.close()
        except Exception:
            pass
        try:
            if raw_socket is not None:
                raw_socket.close()
        except Exception:
            pass


def test_ip_real_delay(ip: str, domain: str, uuid_string: str,
                       path: str = "/", port: int = 443,
                       double_check: bool = True) -> ScanResult:
    """Faithful port of testIpRealDelay(): 3 probes when double_check is on."""
    first_attempt = run_single_probe(ip, domain, uuid_string, path, port)
    if not first_attempt.is_success:
        return first_attempt

    if double_check:
        time.sleep(0.060)
        second_attempt = run_single_probe(ip, domain, uuid_string, path, port)
        if not second_attempt.is_success:
            return ScanResult(ip, False, 0, "Unstable connection (packet drop on 2nd probe)")
        time.sleep(0.060)
        third_attempt = run_single_probe(ip, domain, uuid_string, path, port)
        if not third_attempt.is_success:
            return ScanResult(ip, False, 0, "Unstable connection (packet drop on 3rd probe)")
        avg_delay = (first_attempt.delay_ms + second_attempt.delay_ms + third_attempt.delay_ms) // 3
        return ScanResult(ip, True, avg_delay)

    return first_attempt


def scan_subnet(subnet_prefix: str, domain: str, uuid_string: str,
                path: str = "/", port: int = 443, max_workers: int = 12,
                on_progress: Optional[Callable[[int, int, ScanResult], None]] = None,
                should_stop: Optional[Callable[[], bool]] = None) -> list[ScanResult]:
    """Scan .1-.254 of a subnet prefix in parallel, sorted by delay.

    Android MainActivity uses a fixed pool of 12 threads and launches one
    job per IP; results are kept sorted by delayMs as they arrive.
    """
    prefix = subnet_prefix if subnet_prefix.endswith(".") else subnet_prefix + "."
    all_ips = [f"{prefix}{i}" for i in range(1, 255)]
    results: list[ScanResult] = []
    completed = 0

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_ip = {
            executor.submit(test_ip_real_delay, ip, domain, uuid_string, path, port, True): ip
            for ip in all_ips
        }
        for future in as_completed(future_to_ip):
            if should_stop is not None and should_stop():
                for f in future_to_ip:
                    f.cancel()
                break
            completed += 1
            try:
                result = future.result()
            except Exception as e:
                result = ScanResult(future_to_ip[future], False, 0, str(e))
            if result.is_success:
                results.append(result)
                results.sort(key=lambda r: r.delay_ms)
            if on_progress is not None:
                on_progress(completed, 254, result)

    results.sort(key=lambda r: r.delay_ms)
    return results