From 9b440432371b73d5882da5e8685e4831f4402668 Mon Sep 17 00:00:00 2001 From: Username Date: Sun, 28 Dec 2025 14:56:46 +0100 Subject: [PATCH] add ssl_first: try SSL handshake before secondary check 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) --- config.ini.sample | 7 +- config.py | 7 +- proxywatchd.py | 199 +++++++++++++++++++++++++++++----------------- 3 files changed, 135 insertions(+), 78 deletions(-) diff --git a/config.ini.sample b/config.ini.sample index 9e01824..b3db228 100644 --- a/config.ini.sample +++ b/config.ini.sample @@ -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 diff --git a/config.py b/config.py index 715d126..99156d1 100644 --- a/config.py +++ b/config.py @@ -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) diff --git a/proxywatchd.py b/proxywatchd.py index 5919f31..6baa5ea 100644 --- a/proxywatchd.py +++ b/proxywatchd.py @@ -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