Initialize Clash Web source and deployment documentation

This commit is contained in:
lhc committed 2026-10-05 17:32:00 +08:00
commit 8e0fa00a73
66 files changed
+8532

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
export PATH=/usr/sbin:/usr/bin:/sbin:/bin
if [ "$#" -ne 1 ]; then
echo 'Usage: clashweb-control start|stop|restart' >&2
exit 2
fi
case "$1" in
start|stop|restart) exec /usr/bin/systemctl --no-pager "$1" clashweb-mihomo.service ;;
*) echo 'Unsupported action' >&2; exit 2 ;;
esac
+83
View File
@@ -0,0 +1,83 @@
#!/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())
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Download a pinned official release, verify GitHub's asset SHA256, and unpack it."""
import argparse
import gzip
import hashlib
import json
import os
import platform
import tempfile
import urllib.request
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument('--version', default='v1.19.32')
parser.add_argument('--output', default='bin/mihomo')
args = parser.parse_args()
machine = platform.machine()
arch = {'x86_64': 'amd64-compatible', 'aarch64': 'arm64'}.get(machine)
if arch is None:
raise SystemExit(f'Unsupported architecture: {machine}')
if not args.version.startswith('v') or any(c not in 'v0123456789.' for c in args.version):
raise SystemExit('Use an explicit stable version such as v1.19.32')
request = urllib.request.Request(f'https://api.github.com/repos/MetaCubeX/mihomo/releases/tags/{args.version}', headers={'User-Agent': 'ClashWeb-build'})
with urllib.request.urlopen(request, timeout=30) as response:
release = json.load(response)
name = f'mihomo-linux-{arch}-{args.version}.gz'
asset = next((a for a in release['assets'] if a['name'] == name), None)
if not asset or not asset.get('digest', '').startswith('sha256:'):
raise SystemExit('Release asset or authoritative SHA256 digest is missing; download aborted.')
print(f'Downloading {name}', flush=True)
with urllib.request.urlopen(asset['browser_download_url'], timeout=120) as response:
compressed = response.read(100 * 1024 * 1024 + 1)
if len(compressed) > 100 * 1024 * 1024:
raise SystemExit('Asset exceeds download limit')
if hashlib.sha256(compressed).hexdigest() != asset['digest'].split(':', 1)[1]:
raise SystemExit('SHA256 mismatch; download aborted')
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(dir=output.parent, prefix='.mihomo-')
try:
with os.fdopen(fd, 'wb') as stream:
stream.write(gzip.decompress(compressed))
os.chmod(temporary, 0o755)
os.replace(temporary, output)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
print(f'Verified {asset["digest"]}; installed to {output}')
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Cache mainland domain/IP rule sets locally. Does not restart any service."""
import argparse
import os
import tempfile
import urllib.request
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument('--output', default='data/mihomo/rules')
args = parser.parse_args()
directory = Path(args.output)
directory.mkdir(parents=True, exist_ok=True)
files = {'cn-domain.mrs': 'geosite/cn.mrs', 'cn-ip.mrs': 'geoip/cn.mrs'}
downloads = {}
for name, remote in files.items():
request = urllib.request.Request('https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/' + remote, headers={'User-Agent': 'ClashWeb-build'})
with urllib.request.urlopen(request, timeout=60) as response:
data = response.read(32 * 1024 * 1024 + 1)
# MRS resources use a Zstandard frame; core validation checks the decoded payload.
if len(data) > 32 * 1024 * 1024 or data[:4] != b'\x28\xb5\x2f\xfd':
raise SystemExit(f'{name}: invalid MRS resource or size exceeds limit')
downloads[name] = data
for name, data in downloads.items():
fd, temporary = tempfile.mkstemp(dir=directory, prefix='.rules-')
try:
with os.fdopen(fd, 'wb') as stream:
stream.write(data)
os.chmod(temporary, 0o640)
os.replace(temporary, directory / name)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
print(f'Cached {name}: {len(data)} bytes')
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
# Run on the PVE service VM only. Does not enable TUN, DHCP, or change routes.
task_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
bind='127.0.0.1:8080'
confirmed=false
while (($#)); do
case "$1" in
--target-vm) confirmed=true; shift ;;
--bind) bind=${2:?missing bind address}; shift 2 ;;
*) echo "Usage: sudo scripts/install.sh --target-vm --bind VM_IP:8080" >&2; exit 2 ;;
esac
done
[[ $EUID -eq 0 && $confirmed == true ]] || { echo 'Run as root on the PVE service VM with --target-vm.' >&2; exit 1; }
python3 - "$bind" <<'PY'
import ipaddress, sys
address, port = sys.argv[1].rsplit(':', 1)
ip = ipaddress.ip_address(address)
assert ip.version == 4 and (ip.is_private or ip.is_loopback) and port == '8080', 'Use VM private IPv4:8080'
PY
for required in "$task_root/bin/clashweb" "$task_root/bin/mihomo" "$task_root/data/mihomo/rules/cn-domain.mrs" "$task_root/data/mihomo/rules/cn-ip.mrs"; do
[[ -f "$required" ]] || { echo "Missing $required; see README build instructions." >&2; exit 1; }
done
for command in systemctl useradd groupadd sudo visudo; do command -v "$command" >/dev/null || { echo "Missing $command" >&2; exit 1; }; done
getent group clashweb-core >/dev/null || groupadd --system clashweb-core
id clashweb >/dev/null 2>&1 || useradd --system --user-group --no-create-home --home-dir /var/lib/clashweb --shell /usr/sbin/nologin clashweb
id clashweb-mihomo >/dev/null 2>&1 || useradd --system --gid clashweb-core --no-create-home --home-dir /var/lib/clashweb-core --shell /usr/sbin/nologin clashweb-mihomo
usermod -aG clashweb-core clashweb
install -d -m 0700 -o clashweb -g clashweb /var/lib/clashweb
install -d -m 2770 -o clashweb -g clashweb-core /var/lib/clashweb-core /var/lib/clashweb-core/rules
install -d -m 0755 /usr/local/libexec /etc/clashweb
install -m 0755 -o root -g root "$task_root/bin/clashweb" /usr/local/bin/clashweb
install -m 0755 -o root -g root "$task_root/bin/mihomo" /usr/local/bin/mihomo
install -m 0755 -o root -g root "$task_root/scripts/clashweb-control" /usr/local/libexec/clashweb-control
install -m 0640 -o clashweb -g clashweb-core "$task_root/data/mihomo/rules/"*.mrs /var/lib/clashweb-core/rules/
if [[ -f /etc/clashweb/env ]]; then cp -a /etc/clashweb/env "/etc/clashweb/env.backup-$(date +%Y%m%d-%H%M%S)"; fi
cat > /etc/clashweb/env <<EOF
CLASHWEB_LISTEN=$bind
CLASHWEB_DATA_DIR=/var/lib/clashweb
CLASHWEB_CORE_DIR=/var/lib/clashweb-core
CLASHWEB_MIHOMO_BIN=/usr/local/bin/mihomo
CLASHWEB_CONTROL_HELPER=/usr/local/libexec/clashweb-control
EOF
chmod 0644 /etc/clashweb/env
task_sudoers=$(mktemp)
trap 'rm -f "$task_sudoers"' EXIT
cat > "$task_sudoers" <<'EOF'
clashweb ALL=(root) NOPASSWD: /usr/local/libexec/clashweb-control start, /usr/local/libexec/clashweb-control stop, /usr/local/libexec/clashweb-control restart
EOF
visudo -cf "$task_sudoers"
install -m 0440 -o root -g root "$task_sudoers" /etc/sudoers.d/clashweb
sudo -u clashweb env CLASHWEB_DATA_DIR=/var/lib/clashweb CLASHWEB_CORE_DIR=/var/lib/clashweb-core CLASHWEB_MIHOMO_BIN=/usr/local/bin/mihomo /usr/local/bin/clashweb --init
install -m 0644 "$task_root/deploy/clashweb.service" "$task_root/deploy/clashweb-mihomo.service" /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now clashweb-mihomo.service clashweb.service
systemctl restart clashweb-mihomo.service clashweb.service
systemctl is-active --quiet clashweb-mihomo.service clashweb.service
echo "Installed on service VM. Open http://$bind"
echo 'First-run token: journalctl -u clashweb -n 30'
echo 'TUN remains disabled. Follow docs/deployment.md before enabling gateway mode.'
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Explicit, reversible network preparation on the service VM; never manages DHCP."""
import argparse
import ipaddress
import json
import os
import re
import subprocess
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=['check', 'apply', 'rollback'])
parser.add_argument('--target-vm', action='store_true')
parser.add_argument('--interface')
parser.add_argument('--lan-cidr')
args = parser.parse_args()
backup = Path('/var/lib/clashweb-network/backup.json')
sysctl_file = Path('/etc/sysctl.d/90-clashweb.conf')
nft_file = Path('/etc/clashweb/network.nft')
service_file = Path('/etc/systemd/system/clashweb-network.service')
def run(command, **kwargs):
return subprocess.run(command, check=True, text=True, capture_output=True, **kwargs).stdout
def sysctl_get(key):
return run(['sysctl', '-n', key]).strip()
if args.action == 'check':
print(run(['ip', '-br', '-4', 'address']))
print(run(['ip', '-4', 'route']))
print('IPv4 forwarding:', sysctl_get('net.ipv4.ip_forward'))
print('/dev/net/tun:', Path('/dev/net/tun').exists())
print('No DHCP changes are performed. Use manual client gateway/DNS configuration.')
raise SystemExit(0)
if os.geteuid() != 0 or not args.target_vm:
raise SystemExit('Mutations require root and --target-vm on the PVE service VM.')
if args.action == 'rollback':
if not backup.exists():
raise SystemExit('No Clash Web network backup found.')
data = json.loads(backup.read_text())
# Also recover an interrupted apply that never finished installing the unit.
subprocess.run(['systemctl', 'disable', '--now', 'clashweb-network.service'], capture_output=True)
subprocess.run(['nft', 'delete', 'table', 'ip', 'clashweb_nat'], capture_output=True)
for path in (sysctl_file, nft_file, service_file):
path.unlink(missing_ok=True)
# ip_forward changes reset some IPv4 settings, so restore it first.
for key, value in data['sysctls'].items():
run(['sysctl', '-w', key + '=' + value])
run(['systemctl', 'daemon-reload'])
backup.unlink()
print('Own network rules removed; saved sysctls restored. Restore client gateway/DNS to the modem.')
raise SystemExit(0)
if not args.interface or not re.fullmatch(r'[a-zA-Z0-9_.:-]{1,15}', args.interface):
raise SystemExit('Provide a valid --interface.')
if not args.lan_cidr:
raise SystemExit('Provide --lan-cidr, for example 192.168.1.0/24.')
network = ipaddress.ip_network(args.lan_cidr, strict=True)
if network.version != 4 or not network.is_private or not 8 <= network.prefixlen <= 30:
raise SystemExit('Use a private IPv4 subnet with prefix 8–30.')
addresses = json.loads(run(['ip', '-j', '-4', 'address', 'show', 'dev', args.interface]))
if not any(ipaddress.ip_address(a['local']) in network for i in addresses for a in i['addr_info']):
raise SystemExit('The interface does not have an IPv4 address inside this subnet.')
if backup.exists():
raise SystemExit('Network preparation already applied. Roll back before changing interface/subnet.')
for path in (sysctl_file, nft_file, service_file):
if path.exists():
raise SystemExit(f'Refusing to overwrite existing {path}.')
if subprocess.run(['nft', 'list', 'table', 'ip', 'clashweb_nat'], check=False, capture_output=True).returncode == 0:
parser.error('Table clashweb_nat already exists.')
keys = ['net.ipv4.ip_forward', 'net.ipv4.conf.all.send_redirects', 'net.ipv4.conf.default.send_redirects', f'net.ipv4.conf.{args.interface}.send_redirects', 'net.ipv4.conf.all.rp_filter', f'net.ipv4.conf.{args.interface}.rp_filter']
backup.parent.mkdir(parents=True, exist_ok=True)
backup.write_text(json.dumps({'sysctls': {key: sysctl_get(key) for key in keys}}, indent=2))
os.chmod(backup, 0o600)
values = {key: ('1' if key.endswith('ip_forward') else '2' if key.endswith('rp_filter') else '0') for key in keys}
sysctl_file.write_text('\n'.join(f'{key}={value}' for key, value in values.items()) + '\n')
nft_file.parent.mkdir(parents=True, exist_ok=True)
nft_file.write_text(f'''table ip clashweb_nat {{
chain postrouting {{
type nat hook postrouting priority srcnat; policy accept;
ip saddr {network} ip daddr != {network} oifname "{args.interface}" masquerade
}}
}}
''')
run(['nft', '-c', '-f', str(nft_file)])
service_file.write_text('''[Unit]
Description=Clash Web single-arm gateway NAT
After=network-online.target
Before=clashweb-mihomo.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /etc/clashweb/network.nft
ExecStop=/usr/sbin/nft delete table ip clashweb_nat
[Install]
WantedBy=multi-user.target
''')
for key, value in values.items():
run(['sysctl', '-w', key + '=' + value])
run(['systemctl', 'daemon-reload'])
run(['systemctl', 'enable', '--now', 'clashweb-network.service'])
print('VM forwarding and own NAT table configured. TUN is still controlled by the UI.')
print('Do not enable DHCP on this VM. Configure a test client manually.')
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Package already-built Linux amd64 resources; never include runtime state."""
import argparse
import datetime
import hashlib
import json
import subprocess
import tarfile
import tempfile
from pathlib import Path
import shutil
root = Path(__file__).resolve().parent.parent
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output-dir', type=Path, default=root)
args = parser.parse_args()
args.output_dir.mkdir(parents=True, exist_ok=True)
(root / '.cache').mkdir(exist_ok=True)
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
for name in ('clashweb', 'mihomo'):
header = (root / 'bin' / name).read_bytes()[:64]
if header[:5] != b'\x7fELF\x02' or int.from_bytes(header[18:20], 'little') != 62:
raise SystemExit(f'bin/{name} must be a Linux amd64 ELF binary')
update_readme = '''# Clash Web 管理程序更新(Linux amd64)
本包更新 Clash Web 管理服务及其嵌入的 Web UI。
无需更新 Mihomo 或规则,也不修改数据库、管理员密码、订阅和 VM 网络设置。
在已部署的目标 VM 上解压并进入本目录,然后执行:
```bash
sha256sum -c SHA256SUMS
sudo install -m 0755 -o root -g root /usr/local/bin/clashweb /usr/local/bin/clashweb.previous
sudo install -m 0755 -o root -g root bin/clashweb /usr/local/bin/clashweb.next
sudo mv /usr/local/bin/clashweb.next /usr/local/bin/clashweb
sudo systemctl restart clashweb
sudo systemctl status clashweb --no-pager
```
重启后刷新浏览器,确认管理服务正常。独立 Mihomo 内核继续运行;若页面仍显示旧内容,可用 Ctrl+Shift+R 刷新。
需要回退时执行:
```bash
sudo install -m 0755 -o root -g root /usr/local/bin/clashweb.previous /usr/local/bin/clashweb.rollback
sudo mv /usr/local/bin/clashweb.rollback /usr/local/bin/clashweb
sudo systemctl restart clashweb
```
测试只检查 HTTPS 主页,不承诺流媒体解锁、AI 账号或镜像拉取。
完整说明见 docs/upgrading.md。
'''
deploy_readme = '''# Clash Web Linux amd64 部署包
包含静态 Go 管理程序与 Web UI、Mihomo v1.19.32、已有大陆规则、安装脚本和文档。
在正式 PVE VM 上部署;不包含任何订阅、管理员密码或开发机运行数据。
解压后进入目录,先运行 `sha256sum -c SHA256SUMS`。
首次安装见 docs/deployment.md;Ubuntu/Debian 需要 sudo、python3、iproute2、nftables、ca-certificates。
运行 `sudo scripts/install.sh --target-vm --bind VM_IP:8080`。
VM 固定 IP、转发、防火墙与客户端网关/DNS 按部署文档配置,VM 不运行 DHCP。
已经部署的 VM 推荐使用 clashweb-update-linux-amd64.tar.gz,只替换管理程序。
不要用包内空数据覆盖 /var/lib/clashweb 或 /var/lib/clashweb-core。
'''
scripts = ['install.sh', 'clashweb-control', 'network-vm.py', 'fetch-mihomo.py',
'fetch-rules.py', 'update-rules.sh', 'diagnose-subscription.py']
with tempfile.TemporaryDirectory(prefix='package-', dir=root / '.cache') as temp:
for name, full in [('clashweb-linux-amd64', True), ('clashweb-update-linux-amd64', False)]:
stage = Path(temp) / name
files = ['bin/clashweb']
if full:
files += ['bin/mihomo', 'data/mihomo/rules/cn-domain.mrs', 'data/mihomo/rules/cn-ip.mrs']
files += ['scripts/' + script for script in scripts]
files += [p.relative_to(root).as_posix() for p in (root / 'deploy').glob('*.service')]
files += ['README.md']
files += ['docs/' + document for document in ['api.md', 'development.md', 'deployment.md', 'upgrading.md', 'third-party.md']]
else:
files += ['docs/upgrading.md']
for relative in files:
destination = stage / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(root / relative, destination)
(stage / 'README-DEPLOY.md' if full else stage / 'README-UPDATE.md').write_text(deploy_readme if full else update_readme)
(stage / 'BUILD-INFO.json').write_text(json.dumps({
'platform': 'linux/amd64', 'goamd64': 'v1', 'cgoEnabled': False,
'createdAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'clashwebSha256': digest(root / 'bin/clashweb'),
'mihomoVersion': 'v1.19.32' if full else None,
}, indent=2) + '\n')
payload = sorted(p for p in stage.rglob('*') if p.is_file())
(stage / 'SHA256SUMS').write_text(''.join(f'{digest(p)} {p.relative_to(stage).as_posix()}\n' for p in payload))
archive = args.output_dir / f'{name}.tar.gz'
with tarfile.open(archive, 'w:gz') as tar:
for p in sorted(stage.rglob('*')):
if p.is_file():
tar.add(p, arcname=name + '/' + p.relative_to(stage).as_posix(), recursive=False)
archive.with_suffix(archive.suffix + '.sha256').write_text(f'{digest(archive)} {archive.name}\n')
# Verify the actual archive, not just its staging directory.
with tarfile.open(archive) as tar:
extracted = Path(temp) / (name + '-verify')
tar.extractall(extracted, filter='data')
checked = subprocess.run(['sha256sum', '-c', 'SHA256SUMS'], cwd=extracted / name, capture_output=True)
if checked.returncode:
raise SystemExit('Package payload checksum failed')
if full:
clean = Path(temp) / 'clean-init'
initialized = subprocess.run([str(extracted / name / 'bin/clashweb'), '--init',
'--data', str(clean / 'app'), '--core-dir', str(extracted / name / 'data/mihomo'),
'--mihomo', str(extracted / name / 'bin/mihomo')], capture_output=True)
if initialized.returncode:
raise SystemExit('Clean package initialization/core validation failed')
print(f'{archive}: {archive.stat().st_size:,} bytes, payload checksums verified')
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Run the browser against an isolated real backend/core and a local subscription fixture."""
import contextlib
import http.server
import json
import os
import shutil
import socket
import sqlite3
import subprocess
import tempfile
import threading
import time
import urllib.request
from pathlib import Path
root = Path(__file__).resolve().parent.parent
for port in (18080, 9090, 7890, 1053):
with socket.socket() as probe:
if probe.connect_ex(('127.0.0.1', port)) == 0:
raise SystemExit(f'Port {port} is occupied. E2E aborted to avoid changing another service.')
class Fixture(http.server.BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def do_CONNECT(self):
self.send_response(200, 'Connection Established')
self.end_headers()
self.close_connection = False
self.handle_one_request()
def do_HEAD(self):
# Mihomo's unified-delay check repeats HEAD on the established tunnel.
# Supply a framed keep-alive response, rather than the default HTTP 501.
time.sleep(0.03)
self.send_response(200)
self.send_header('Content-Length', '0')
self.end_headers()
def do_GET(self):
if self.path == '/subscription':
data = 'proxies:\n'
for name in ['香港 · 测试节点', '日本 · 测试节点', '新加坡 · 测试节点']:
data += f' - name: {name}\n type: http\n server: 127.0.0.1\n port: {self.server.server_port}\n'
body = data.encode()
else:
# Keep successful local health checks above Mihomo's integer-ms zero.
time.sleep(0.03)
body = b'local-e2e-proxy'
self.send_response(200)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
fixture = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Fixture)
threading.Thread(target=fixture.serve_forever, daemon=True).start()
def wait_url(url, timeout=20):
until = time.monotonic() + timeout
while time.monotonic() < until:
try:
with urllib.request.urlopen(url, timeout=1) as response:
return response.status
except Exception:
time.sleep(0.1)
raise RuntimeError(f'Service failed to start: {url}')
root.joinpath('.cache').mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix='e2e-', dir=root / '.cache') as temporary:
work = Path(temporary)
core = work / 'mihomo'
shutil.copytree(root / 'data/mihomo/rules', core / 'rules')
command = [str(root / 'bin/clashweb'), '--data', str(work / 'app'), '--core-dir', str(core), '--mihomo', str(root / 'bin/mihomo')]
initialized = subprocess.run(command + ['--init'], text=True, capture_output=True)
if initialized.returncode:
raise SystemExit('Initialization failed: ' + initialized.stderr)
with contextlib.closing(sqlite3.connect(work / 'app/clashweb.sqlite')) as db:
token = db.execute("SELECT value FROM kv WHERE key='setup_token'").fetchone()[0]
processes = []
try:
with open(work / 'core.log', 'w') as log:
processes.append(subprocess.Popen([str(root / 'bin/mihomo'), '-d', str(core), '-f', str(core / 'config.yaml')], stdout=log, stderr=log))
with open(work / 'app.log', 'w') as log:
processes.append(subprocess.Popen(command + ['--listen', '127.0.0.1:18080'], stdout=log, stderr=log))
wait_url('http://127.0.0.1:18080/api/v1/auth/status')
environment = dict(os.environ, CW_E2E_URL='http://127.0.0.1:18080', CW_E2E_SETUP_TOKEN=token, CW_E2E_SUBSCRIPTION_URL=f'http://127.0.0.1:{fixture.server_port}/subscription')
result = subprocess.run(['npm', 'run', 'test:e2e'], cwd=root / 'web', env=environment)
if result.returncode:
# This harness contains only credential-free local proxy fixtures.
print('Local fixture core log:', (work / 'core.log').read_text()[-8000:])
raise SystemExit(result.returncode)
finally:
for process in reversed(processes):
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
fixture.shutdown()
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
[[ $EUID -eq 0 ]] || { echo 'Run on the service VM as root.' >&2; exit 1; }
task_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
task_staging=$(mktemp -d)
trap 'rm -rf "$task_staging"' EXIT
python3 "$task_root/scripts/fetch-rules.py" --output "$task_staging/new"
mkdir -p "$task_staging/old"
cp -a /var/lib/clashweb-core/rules/. "$task_staging/old/"
install -m 0640 -o clashweb -g clashweb-core "$task_staging/new/"*.mrs /var/lib/clashweb-core/rules/
if ! sudo -u clashweb-mihomo /usr/local/bin/mihomo -t -d /var/lib/clashweb-core -f /var/lib/clashweb-core/config.yaml; then
cp -a "$task_staging/old/." /var/lib/clashweb-core/rules/
echo 'Validation failed; previous rules restored.' >&2
exit 1
fi
if ! systemctl restart clashweb-mihomo.service || ! systemctl is-active --quiet clashweb-mihomo.service; then
cp -a "$task_staging/old/." /var/lib/clashweb-core/rules/
systemctl restart clashweb-mihomo.service
echo 'Reload failed; previous rules restored.' >&2
exit 1
fi
echo 'Rule resources updated. Select a node in the UI if needed.'