Files
clash-web/scripts/diagnose-subscription.py

84 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Read-only VM diagnostics. Never prints subscription URLs or node credentials."""
import argparse
import json
import os
from pathlib import Path
import sqlite3
import subprocess
import sys
from urllib.parse import urlsplit
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--host', required=True, help='Hostname of a saved subscription')
parser.add_argument('--data-dir', default='/var/lib/clashweb')
parser.add_argument('--proxy', help='Override the saved VM Mihomo HTTP proxy address')
args = parser.parse_args()
if os.geteuid() != 0:
raise SystemExit('Run with sudo to read the protected VM database; all network tests run as user clashweb.')
database = (Path(args.data_dir) / 'clashweb.sqlite').resolve()
with sqlite3.connect(database.as_uri() + '?mode=ro', uri=True) as db:
row = db.execute("SELECT value FROM kv WHERE key='state'").fetchone()
if not row:
raise SystemExit('No saved state found.')
state = json.loads(row[0])
settings = state.get('settings', {})
proxy = args.proxy or f"http://{settings.get('lanIP') or '127.0.0.1'}:{settings.get('mixedPort', 7890)}"
subscriptions = [s for s in state.get('subscriptions', []) if urlsplit(s.get('url', '')).hostname == args.host]
if not subscriptions:
raise SystemExit('No saved subscription matches this hostname.')
code = r'''
import hashlib,json,os,pathlib,socket,ssl,time,urllib.error,urllib.request
from urllib.parse import urlsplit
payload=json.loads(__import__('sys').stdin.read())
url=payload['url'];host=urlsplit(url).hostname
class SafeRedirect(urllib.request.HTTPRedirectHandler):
max_redirections=5
def redirect_request(self,request,fp,code,msg,headers,newurl):
target=urlsplit(newurl)
if target.scheme not in ('http','https') or target.username is not None:
raise urllib.error.URLError('Unsupported redirect')
return super().redirect_request(request,fp,code,msg,headers,newurl)
verify=ssl.get_default_verify_paths()
result={'host':host,'runAsUID':os.getuid(),'caBundleExists':bool(verify.cafile and pathlib.Path(verify.cafile).exists()),'utcTime':time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),'tests':[]}
try:
addresses=socket.getaddrinfo(host,443,type=socket.SOCK_STREAM)
result['systemDNSAddresses']=sorted({a[4][0] for a in addresses})
except Exception as error:result['systemDNSError']=type(error).__name__
for mode,proxy in [('direct',{}),('vm-proxy',{'http':payload['proxy'],'https':payload['proxy']})]:
started=time.monotonic();entry={'mode':mode}
try:
opener=urllib.request.build_opener(urllib.request.ProxyHandler(proxy),SafeRedirect())
request=urllib.request.Request(url,headers={'User-Agent':'clash.meta/ClashWeb'})
try:response=opener.open(request,timeout=20)
except urllib.error.HTTPError as error:response=error
with response:
body=response.read(8*1024*1024+1)
entry.update({'httpStatus':response.status,'contentType':response.headers.get('Content-Type'),'bytes':len(body),'sha256':hashlib.sha256(body).hexdigest(),'hasClashProxies':b'proxies:' in body,'cfChallenge':response.headers.get('cf-mitigated')=='challenge'})
except Exception as error:
reason=getattr(error,'reason',error)
entry['errorType']=type(reason).__name__
if isinstance(reason,ssl.SSLCertVerificationError):
entry['certificateErrorCode']=reason.verify_code;entry['detail']=reason.verify_message
elif isinstance(reason,socket.gaierror):entry['detail']='System DNS lookup failed'
elif isinstance(reason,(socket.timeout,TimeoutError)):entry['detail']='Connection or read timed out'
else:
entry['detail']='Network connection failed'
if getattr(reason,'errno',None) is not None:entry['errno']=reason.errno
entry['seconds']=round(time.monotonic()-started,2);result['tests'].append(entry)
print(json.dumps(result,ensure_ascii=False,indent=2))
'''
for subscription in subscriptions:
try:
result = subprocess.run(
['/usr/bin/sudo', '-n', '-u', 'clashweb', 'python3', '-c', code],
input=json.dumps({'url': subscription['url'], 'proxy': proxy}),
text=True, capture_output=True, timeout=60,
)
except subprocess.TimeoutExpired:
raise SystemExit('Diagnostics timed out; inspect VM system DNS and connectivity.')
if result.returncode:
print('Diagnostics subprocess failed; no stored URL or subscription body has been printed.', file=sys.stderr)
raise SystemExit(result.returncode)
print(result.stdout.strip())