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