watchd: add tor checktype, use Tor API for secondary check
All checks were successful
CI / syntax-check (push) Successful in 3s
CI / memory-leak-check (push) Successful in 11s

This commit is contained in:
Username
2025-12-26 21:20:16 +01:00
parent d2bd7d4f34
commit f7a762331a

View File

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