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 file for proxy storage
database = proxies.sqlite 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 # judges - HTTP judge servers that echo back request headers
# ssl - TLS handshake test (port 443, verifies MITM)
# irc - IRC server connection test (port 6667) # irc - IRC server connection test (port 6667)
# head - HTTP HEAD request test (port 80) # head - HTTP HEAD request test (port 80)
# tor - Tor exit check via check.torproject.org (port 80)
checktype = head checktype = head
# Thread configuration # Thread configuration

View File

@@ -51,8 +51,8 @@ class Config(ComboParser):
if self.ppf.max_fail < 1: if self.ppf.max_fail < 1:
errors.append('ppf.max_fail must be >= 1') errors.append('ppf.max_fail must be >= 1')
# Validate checktypes (comma-separated list) # Validate checktypes (secondary check types, ssl is handled by ssl_first)
valid_checktypes = {'irc', 'head', 'judges', 'ssl'} valid_checktypes = {'irc', 'head', 'judges'}
for ct in self.watchd.checktypes: for ct in self.watchd.checktypes:
if ct not in valid_checktypes: if ct not in valid_checktypes:
errors.append('watchd.checktype "%s" invalid, must be one of: %s' % (ct, ', '.join(sorted(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, '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, '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, '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_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) 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() sock.disconnect()
def _connect_and_test(self): 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 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 # Always log first test to verify code path
global _sample_debug_counter global _sample_debug_counter
if _sample_debug_counter == 0: if _sample_debug_counter == 0:
_log('FIRST TEST: proxy=%s target=%s check=%s' % (ps.proxy, self.target_srv, self.checktype), 'info') _log('FIRST TEST: proxy=%s target=%s check=%s ssl_first=%s' % (
_sample_dbg('TEST START: proxy=%s target=%s check=%s' % (ps.proxy, self.target_srv, self.checktype), ps.proxy) 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 srvname = self.target_srv.strip() if self.checktype == 'irc' else self.target_srv
# For judges, extract host from 'host/path' format # For judges, extract host from 'host/path' format
if self.checktype == 'judges' and '/' in srvname: if self.checktype == 'judges' and '/' in srvname:
connect_host = srvname.split('/')[0] connect_host = srvname.split('/')[0]
else: else:
connect_host = srvname connect_host = srvname
# SSL checktype: always use SSL with certificate verification # Secondary checks: always use plain HTTP
if self.checktype == 'ssl': use_ssl = 0
use_ssl = 1 verifycert = False
ssl_only_check = True # handshake only, no HTTP request if self.checktype == 'irc':
server_port = 443 server_port = 6667
verifycert = True
else: else:
# head, judges, irc: always use plain HTTP server_port = 80
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
# Get Tor host from pool (with worker affinity) last_error_category = None
pool = connection_pool.get_pool()
for proto in protos: for proto in protos:
if pool: if pool:
@@ -1527,14 +1632,6 @@ class TargetTestJob():
_dbg('connected OK', ps.proxy) _dbg('connected OK', ps.proxy)
_sample_dbg('CONNECTED OK: %s via %s' % (ps.proxy, proto), 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': if self.checktype == 'irc':
sock.send('NICK\n') sock.send('NICK\n')
elif self.checktype == 'judges': elif self.checktype == 'judges':
@@ -1602,48 +1699,6 @@ class TargetTestJob():
if config.watchd.debug: if config.watchd.debug:
_log('failed to extract MITM cert: %s' % str(e), 'debug') _log('failed to extract MITM cert: %s' % str(e), 'debug')
return None, proto, duration, torhost, srvname, 0, use_ssl, 'ssl_mitm' 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: except KeyboardInterrupt as e:
raise e raise e