add ssl_first: try SSL handshake before secondary check
All checks were successful
CI / syntax-check (push) Successful in 3s
CI / memory-leak-check (push) Successful in 11s

When ssl_first=1 (default), proxy validation first attempts an SSL
handshake. If it fails, falls back to the configured secondary check
(head, judges, or irc). This separates SSL capability detection from
basic connectivity testing.

New config options:
- ssl_first: enable SSL-first pattern (default: 1)
- checktype: secondary check type (head, judges, irc)
This commit is contained in:
Username
2025-12-28 14:56:46 +01:00
parent 9f782c3222
commit 9b44043237
3 changed files with 135 additions and 78 deletions

View File

@@ -18,12 +18,13 @@ profiling = 0
# Database file for proxy storage
database = proxies.sqlite
# Check type(s): judges, ssl, irc, head, tor (comma-separated for random selection)
# SSL-first mode: try SSL handshake first, fallback to secondary check on failure
ssl_first = 1
# Secondary check type (used when ssl_first fails, or as primary when ssl_first=0)
# judges - HTTP judge servers that echo back request headers
# ssl - TLS handshake test (port 443, verifies MITM)
# irc - IRC server connection test (port 6667)
# head - HTTP HEAD request test (port 80)
# tor - Tor exit check via check.torproject.org (port 80)
checktype = head
# Thread configuration

View File

@@ -51,8 +51,8 @@ class Config(ComboParser):
if self.ppf.max_fail < 1:
errors.append('ppf.max_fail must be >= 1')
# Validate checktypes (comma-separated list)
valid_checktypes = {'irc', 'head', 'judges', 'ssl'}
# Validate checktypes (secondary check types, ssl is handled by ssl_first)
valid_checktypes = {'irc', 'head', 'judges'}
for ct in self.watchd.checktypes:
if ct not in valid_checktypes:
errors.append('watchd.checktype "%s" invalid, must be one of: %s' % (ct, ', '.join(sorted(valid_checktypes))))
@@ -111,7 +111,8 @@ class Config(ComboParser):
self.add_item(section, 'stale_days', int, 30, 'days after which dead proxies are removed (default: 30)', False)
self.add_item(section, 'stats_interval', int, 300, 'seconds between status reports (default: 300)', False)
self.add_item(section, 'tor_safeguard', bool, True, 'enable tor safeguard (default: True)', False)
self.add_item(section, 'checktype', str, 'ssl', 'check type(s): irc, head, judges, ssl (comma-separated for random)', False)
self.add_item(section, 'checktype', str, 'head', 'secondary check type: irc, head, judges (used when ssl_first fails)', False)
self.add_item(section, 'ssl_first', bool, True, 'try SSL handshake first, fallback to checktype on failure (default: True)', False)
self.add_item(section, 'scale_cooldown', int, 10, 'seconds between thread scaling decisions (default: 10)', False)
self.add_item(section, 'scale_threshold', float, 10.0, 'min success rate % to scale up threads (default: 10.0)', False)

View File

@@ -1451,41 +1451,146 @@ class TargetTestJob():
sock.disconnect()
def _connect_and_test(self):
"""Connect to target through the proxy and send test packet."""
"""Connect to target through the proxy and send test packet.
If ssl_first is enabled:
1. Try SSL handshake first
2. If SSL succeeds -> return success
3. If SSL fails -> try secondary check (configured checktype)
"""
ps = self.proxy_state
_dbg('_connect_and_test: target=%s checktype=%s' % (self.target_srv, self.checktype), ps.proxy)
_dbg('_connect_and_test: target=%s checktype=%s ssl_first=%s' % (
self.target_srv, self.checktype, config.watchd.ssl_first), ps.proxy)
# Always log first test to verify code path
global _sample_debug_counter
if _sample_debug_counter == 0:
_log('FIRST TEST: proxy=%s target=%s check=%s' % (ps.proxy, self.target_srv, self.checktype), 'info')
_sample_dbg('TEST START: proxy=%s target=%s check=%s' % (ps.proxy, self.target_srv, self.checktype), ps.proxy)
_log('FIRST TEST: proxy=%s target=%s check=%s ssl_first=%s' % (
ps.proxy, self.target_srv, self.checktype, config.watchd.ssl_first), 'info')
protos = ['http', 'socks5', 'socks4'] if ps.proto is None else [ps.proto]
pool = connection_pool.get_pool()
# Phase 1: SSL handshake (if ssl_first enabled)
if config.watchd.ssl_first:
result = self._try_ssl_handshake(protos, pool)
if result is not None:
return result # SSL succeeded or MITM detected
# SSL failed for all protocols, continue to secondary check
_dbg('SSL failed, trying secondary check: %s' % self.checktype, ps.proxy)
# Phase 2: Secondary check (configured checktype)
return self._try_secondary_check(protos, pool)
def _try_ssl_handshake(self, protos, pool):
"""Attempt SSL handshake to verify proxy works with TLS.
Returns:
Tuple on success/MITM, None on failure (should try secondary check)
"""
ps = self.proxy_state
ssl_target = random.choice(ssl_targets)
last_error_category = None
for proto in protos:
if pool:
torhost = pool.get_tor_host(self.worker_id)
else:
torhost = random.choice(config.torhosts)
network_stats.set_tor_node(torhost)
if proto == 'socks4':
srv = socks4_resolve(ssl_target, 443)
else:
srv = ssl_target
if not srv:
continue
duration = time.time()
if ps.auth:
proxy_url = '%s://%s@%s:%s' % (proto, ps.auth, ps.ip, ps.port)
else:
proxy_url = '%s://%s:%s' % (proto, ps.ip, ps.port)
proxies = [
rocksock.RocksockProxyFromURL(tor_proxy_url(torhost)),
rocksock.RocksockProxyFromURL(proxy_url),
]
adaptive_timeout = config.watchd.timeout + min(
ps.failcount * config.watchd.timeout_fail_inc,
config.watchd.timeout_fail_max)
try:
sock = rocksock.Rocksock(host=srv, port=443, ssl=1,
proxies=proxies, timeout=adaptive_timeout,
verifycert=True)
_dbg('SSL handshake: proto=%s tor=%s target=%s' % (proto, torhost, ssl_target), ps.proxy)
sock.connect()
# SSL handshake succeeded
elapsed = time.time() - duration
if pool:
pool.record_success(torhost, elapsed)
sock.disconnect()
_dbg('SSL handshake OK', ps.proxy)
return None, proto, duration, torhost, ssl_target, 0, 1, 'ssl_ok'
except rocksock.RocksockException as e:
last_error_category = categorize_error(e)
et = e.get_errortype()
err = e.get_error()
try:
sock.disconnect()
except:
pass
if et == rocksock.RS_ET_SSL and err == rocksock.RS_E_SSL_CERTIFICATE_ERROR:
# MITM detected - proxy works but intercepts TLS
ps.mitm = 1
elapsed = time.time() - duration
if pool:
pool.record_success(torhost, elapsed)
_dbg('SSL MITM detected', ps.proxy)
return None, proto, duration, torhost, ssl_target, 0, 1, 'ssl_mitm'
if config.watchd.debug:
_log('SSL handshake failed: %s://%s:%d: %s' % (
proto, ps.ip, ps.port, e.get_errormessage()), 'debug')
# Check for Tor connection issues
if et == rocksock.RS_ET_OWN:
if e.get_failedproxy() == 0 and err == rocksock.RS_E_TARGET_CONN_REFUSED:
if pool:
pool.record_failure(torhost)
except KeyboardInterrupt:
raise
# All protocols failed SSL
return None
def _try_secondary_check(self, protos, pool):
"""Try the configured secondary checktype (head, judges, irc)."""
ps = self.proxy_state
_sample_dbg('TEST START: proxy=%s target=%s check=%s' % (
ps.proxy, self.target_srv, self.checktype), ps.proxy)
srvname = self.target_srv.strip() if self.checktype == 'irc' else self.target_srv
# For judges, extract host from 'host/path' format
if self.checktype == 'judges' and '/' in srvname:
connect_host = srvname.split('/')[0]
else:
connect_host = srvname
# SSL checktype: always use SSL with certificate verification
if self.checktype == 'ssl':
use_ssl = 1
ssl_only_check = True # handshake only, no HTTP request
server_port = 443
verifycert = True
# Secondary checks: always use plain HTTP
use_ssl = 0
verifycert = False
if self.checktype == 'irc':
server_port = 6667
else:
# head, judges, irc: always use plain HTTP
use_ssl = 0
ssl_only_check = False
verifycert = False
if self.checktype == 'irc':
server_port = 6667
else:
server_port = 80
protos = ['http', 'socks5', 'socks4'] if ps.proto is None else [ps.proto]
last_error_category = None
server_port = 80
# Get Tor host from pool (with worker affinity)
pool = connection_pool.get_pool()
last_error_category = None
for proto in protos:
if pool:
@@ -1527,14 +1632,6 @@ class TargetTestJob():
_dbg('connected OK', ps.proxy)
_sample_dbg('CONNECTED OK: %s via %s' % (ps.proxy, proto), ps.proxy)
# SSL-only check: handshake passed, no request needed
if ssl_only_check:
elapsed = time.time() - duration
if pool:
pool.record_success(torhost, elapsed)
sock.disconnect()
return None, proto, duration, torhost, srvname, 0, use_ssl, 'ssl_ok'
if self.checktype == 'irc':
sock.send('NICK\n')
elif self.checktype == 'judges':
@@ -1602,48 +1699,6 @@ class TargetTestJob():
if config.watchd.debug:
_log('failed to extract MITM cert: %s' % str(e), 'debug')
return None, proto, duration, torhost, srvname, 0, use_ssl, 'ssl_mitm'
elif et == rocksock.RS_ET_SSL and ssl_only_check:
# SSL handshake failed - check if protocol error vs other error
# fp contains the SSL error reason string
if is_ssl_protocol_error(fp):
# Protocol error (WRONG_VERSION_NUMBER, etc.) - proxy doesn't support SSL
# No fallback needed, just fail this proxy for SSL
if config.watchd.debug:
_log('SSL protocol error, no fallback: %s://%s:%d (%s)' % (proto, ps.ip, ps.port, fp), 'debug')
# Continue to try next protocol
else:
# Other SSL error - verify with HTTP HEAD fallback
try:
sock.disconnect()
except Exception as e:
if config.watchd.debug:
_log('socket disconnect failed: %s' % e, 'debug')
# Delay before secondary check (allows different Tor circuit)
time.sleep(0.3)
if config.watchd.debug:
_log('SSL error, fallback to HTTP HEAD: %s://%s:%d (%s)' % (proto, ps.ip, ps.port, fp), 'debug')
try:
# Secondary check via HTTP HEAD (same as 'head' checktype)
fallback_host = random.choice(list(regexes.keys()))
if ps.auth:
fallback_proxy_url = '%s://%s@%s:%s' % (proto, ps.auth, ps.ip, ps.port)
else:
fallback_proxy_url = '%s://%s:%s' % (proto, ps.ip, ps.port)
fallback_proxies = [
rocksock.RocksockProxyFromURL(tor_proxy_url(torhost)),
rocksock.RocksockProxyFromURL(fallback_proxy_url),
]
fallback_sock = rocksock.Rocksock(host=fallback_host, port=80, ssl=0,
proxies=fallback_proxies, timeout=adaptive_timeout)
fallback_sock.connect()
fallback_sock.send('HEAD / HTTP/1.0\r\nHost: %s\r\n\r\n' % fallback_host)
elapsed = time.time() - duration
if pool:
pool.record_success(torhost, elapsed)
return fallback_sock, proto, duration, torhost, fallback_host, 0, 0, 'ssl_fallback_head'
except rocksock.RocksockException:
pass # Fallback failed, continue to next protocol
except KeyboardInterrupt as e:
raise e