Initialize Clash Web source and deployment documentation
This commit is contained in:
commit
8e0fa00a73
66 files changed
+8532
No files matched your search
Executable
+11
@@ -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
|
||||
@@ -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())
|
||||
Executable
+48
@@ -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}')
|
||||
Executable
+34
@@ -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')
|
||||
Executable
+61
@@ -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.'
|
||||
Executable
+106
@@ -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.')
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
Executable
+22
@@ -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.'
|
||||
Reference in new issue
Block a user