diff --git a/proxywatchd.py b/proxywatchd.py index 6d66d80..d8ac3be 100644 --- a/proxywatchd.py +++ b/proxywatchd.py @@ -273,6 +273,12 @@ ssl_targets = [ 'www.letsencrypt.org', ] +# Tor check targets - verify proxy exits through Tor network +# Response contains JSON with IsTor: true/false +tor_targets = [ + 'check.torproject.org/api/ip', +] + class Stats(): """Track and report comprehensive runtime statistics.""" @@ -1340,11 +1346,16 @@ class TargetTestJob(): try: recv = sock.recv(-1) - # Select regex based on check type - if self.checktype == 'irc': + # Select regex based on check type (or fallback target) + if 'check.torproject.org' in srv: + # Tor API fallback or tor checktype + regex = r'"IsTor"\s*:\s*true' + elif self.checktype == 'irc': regex = '^(:|NOTICE|ERROR)' elif self.checktype == 'judges': regex = judges[srv] + elif self.checktype == 'tor': + regex = r'"IsTor"\s*:\s*true' elif self.checktype == 'ssl': # Should not reach here - ssl returns before recv self.proxy_state.record_result(True, proto=proto, srv=srv, ssl=is_ssl) @@ -1356,19 +1367,20 @@ class TargetTestJob(): if re.search(regex, recv, re.IGNORECASE): elapsed = time.time() - duration _dbg('regex MATCH, elapsed=%.2fs' % elapsed, self.proxy_state.proxy) - # Extract exit IP from judge response + # Extract exit IP from judge/tor response exit_ip = None reveals_headers = None - if self.checktype == 'judges': + if self.checktype == 'judges' or self.checktype == 'tor' or 'check.torproject.org' in srv: ip_match = re.search(IP_PATTERN, recv) if ip_match: exit_ip = ip_match.group(0) - # Check for header echo judge (elite detection) - if 'headers' in srv: - # If X-Forwarded-For/Via/etc present, proxy reveals chain - reveals_headers = bool(re.search(HEADER_REVEAL_PATTERN, recv, re.IGNORECASE)) - # Record successful judge - judge_stats.record_success(srv) + if self.checktype == 'judges' and 'check.torproject.org' not in srv: + # Check for header echo judge (elite detection) + if 'headers' in srv: + # If X-Forwarded-For/Via/etc present, proxy reveals chain + reveals_headers = bool(re.search(HEADER_REVEAL_PATTERN, recv, re.IGNORECASE)) + # Record successful judge + judge_stats.record_success(srv) self.proxy_state.record_result( True, proto=proto, duration=elapsed, srv=srv, tor=tor, ssl=is_ssl, exit_ip=exit_ip, @@ -1410,8 +1422,8 @@ class TargetTestJob(): ps = self.proxy_state _dbg('_connect_and_test: target=%s checktype=%s' % (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: + # For judges/tor, extract host from 'host/path' format + if (self.checktype == 'judges' or self.checktype == 'tor') and '/' in srvname: connect_host = srvname.split('/')[0] else: connect_host = srvname @@ -1422,6 +1434,12 @@ class TargetTestJob(): ssl_only_check = True # handshake only, no HTTP request server_port = 443 verifycert = True + elif self.checktype == 'tor': + # Tor check uses HTTP by default (like judges/head) + use_ssl = random.choice([0, 1]) if config.watchd.use_ssl == 2 else config.watchd.use_ssl + ssl_only_check = False + server_port = 443 if use_ssl else 80 + verifycert = True if use_ssl else False else: use_ssl = random.choice([0, 1]) if config.watchd.use_ssl == 2 else config.watchd.use_ssl ssl_only_check = False # minimal SSL test (handshake only, no request) @@ -1488,8 +1506,8 @@ class TargetTestJob(): if self.checktype == 'irc': sock.send('NICK\n') - elif self.checktype == 'judges': - # GET request to receive body with IP address + elif self.checktype == 'judges' or self.checktype == 'tor': + # GET request to receive body (IP for judges, JSON for tor) sock.send('GET /%s HTTP/1.0\r\nHost: %s\r\n\r\n' % ( srvname.split('/', 1)[1] if '/' in srvname else '', srvname.split('/')[0] @@ -1552,7 +1570,7 @@ class TargetTestJob(): _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 not ssl_only_check: - # SSL failed but proxy protocol worked - fallback to HTTP + # SSL failed but proxy protocol worked - fallback to Tor API check (HTTP) # sock already disconnected above, but ensure cleanup try: sock.disconnect() @@ -1561,28 +1579,28 @@ class TargetTestJob(): # Delay before secondary check (allows different Tor circuit) time.sleep(0.3) if config.watchd.debug: - _log('SSL failed, fallback to HTTP: %s://%s:%d' % (proto, ps.ip, ps.port), 'debug') + _log('SSL failed, fallback to Tor API: %s://%s:%d' % (proto, ps.ip, ps.port), 'debug') try: - http_port = 80 - # New Tor credentials = new circuit + # Secondary check via Tor Project API (plain HTTP) + tor_check_host = 'check.torproject.org' 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) - http_proxies = [ + fallback_proxies = [ rocksock.RocksockProxyFromURL(tor_proxy_url(torhost)), rocksock.RocksockProxyFromURL(fallback_proxy_url), ] - http_sock = rocksock.Rocksock(host=connect_host, port=http_port, ssl=0, - proxies=http_proxies, timeout=adaptive_timeout) - http_sock.connect() - http_sock.send('HEAD / HTTP/1.0\r\nHost: %s\r\n\r\n' % srvname) + fallback_sock = rocksock.Rocksock(host=tor_check_host, port=80, ssl=0, + proxies=fallback_proxies, timeout=adaptive_timeout) + fallback_sock.connect() + fallback_sock.send('GET /api/ip HTTP/1.0\r\nHost: %s\r\n\r\n' % tor_check_host) elapsed = time.time() - duration if pool: pool.record_success(torhost, elapsed) - return http_sock, proto, duration, torhost, srvname, 0, 0, 'ssl_fallback_http' + return fallback_sock, proto, duration, torhost, tor_check_host + '/api/ip', 0, 0, 'ssl_fallback_tor' except rocksock.RocksockException: - pass # HTTP fallback failed, continue to next protocol + pass # Fallback failed, continue to next protocol except KeyboardInterrupt as e: raise e @@ -1845,6 +1863,9 @@ class Proxywatchd(): elif ct == 'ssl': target_pools[ct] = ssl_targets _dbg('target_pool[ssl]: %d targets' % len(ssl_targets)) + elif ct == 'tor': + target_pools[ct] = tor_targets + _dbg('target_pool[tor]: %d targets' % len(tor_targets)) else: # http/head target_pools[ct] = list(regexes.keys()) _dbg('target_pool[%s]: %d targets' % (ct, len(regexes)))