84 lines
4.4 KiB
Python
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())
|